Integration tests for SDN-IP.

Tests that sending add and delete route updates to SDN-IP results in the
expected intents being created.

Change-Id: I34a1b24ec133c077ba1bd7dd30f87c1ad4ce367f
diff --git a/src/main/java/net/onrc/onos/apps/sdnip/SdnIp.java b/src/main/java/net/onrc/onos/apps/sdnip/SdnIp.java
index ed7a8ee..ffa9cfa 100644
--- a/src/main/java/net/onrc/onos/apps/sdnip/SdnIp.java
+++ b/src/main/java/net/onrc/onos/apps/sdnip/SdnIp.java
@@ -513,7 +513,7 @@
      *
      * @param update RIB update
      */
-    private void processRibDelete(RibUpdate update) {
+    protected void processRibDelete(RibUpdate update) {
         synchronized (this) {
             Prefix prefix = update.getPrefix();
 
diff --git a/src/test/java/net/onrc/onos/apps/sdnip/RadixTreeTest.java b/src/test/java/net/onrc/onos/apps/sdnip/RadixTreeTest.java
new file mode 100644
index 0000000..1d1034c
--- /dev/null
+++ b/src/test/java/net/onrc/onos/apps/sdnip/RadixTreeTest.java
@@ -0,0 +1,88 @@
+package net.onrc.onos.apps.sdnip;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import java.net.InetAddress;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+import org.junit.Test;
+
+import com.google.common.net.InetAddresses;
+import com.googlecode.concurrenttrees.radix.node.concrete.DefaultByteArrayNodeFactory;
+import com.googlecode.concurrenttrees.radixinverted.ConcurrentInvertedRadixTree;
+import com.googlecode.concurrenttrees.radixinverted.InvertedRadixTree;
+
+/**
+ * Sanity tests for the InvertedRadixTree.
+ * <p/>
+ * These tests are used to verify that the InvertedRadixTree provides the
+ * functionality we need to fit our use case.
+ */
+public class RadixTreeTest {
+
+    private Map<String, Interface> interfaces;
+    private InvertedRadixTree<Interface> interfaceRoutes;
+
+    private Interface longestInterfacePrefixMatch(InetAddress address) {
+        Prefix prefixToSearchFor = new Prefix(address.getAddress(),
+                Prefix.MAX_PREFIX_LENGTH);
+        Iterator<Interface> it =
+                interfaceRoutes.getValuesForKeysPrefixing(
+                        prefixToSearchFor.toBinaryString()).iterator();
+        Interface intf = null;
+        // Find the last prefix, which will be the longest prefix
+        while (it.hasNext()) {
+            intf = it.next();
+        }
+
+        return intf;
+    }
+
+    /**
+     * This is just a test of the InvertedRadixTree, rather than an actual unit
+     * test of SdnIp. It tests that the algorithm used to retrieve the
+     * longest prefix match from the tree actually does retrieve the longest
+     * prefix, and not just any matching prefix.
+     */
+    @Test
+    public void getOutgoingInterfaceTest() {
+        interfaces = new HashMap<>();
+        interfaceRoutes = new ConcurrentInvertedRadixTree<>(
+                new DefaultByteArrayNodeFactory());
+
+        Interface interface1 = new Interface("sw3-eth1", "00:00:00:00:00:00:00:a3",
+                (short) 1, "192.168.10.101", 24);
+        interfaces.put(interface1.getName(), interface1);
+        Interface interface2 = new Interface("sw5-eth1", "00:00:00:00:00:00:00:a5",
+                (short) 1, "192.168.20.101", 16);
+        interfaces.put(interface2.getName(), interface2);
+        Interface interface3 = new Interface("sw2-eth1", "00:00:00:00:00:00:00:a2",
+                (short) 1, "192.168.60.101", 16);
+        interfaces.put(interface3.getName(), interface3);
+        Interface interface4 = new Interface("sw6-eth1", "00:00:00:00:00:00:00:a6",
+                (short) 1, "192.168.60.101", 30);
+        interfaces.put(interface4.getName(), interface4);
+        Interface interface5 = new Interface("sw4-eth4", "00:00:00:00:00:00:00:a4",
+                (short) 4, "192.168.60.101", 24);
+        interfaces.put(interface5.getName(), interface5);
+
+        for (Interface intf : interfaces.values()) {
+            Prefix prefix = new Prefix(intf.getIpAddress().getAddress(),
+                    intf.getPrefixLength());
+            interfaceRoutes.put(prefix.toBinaryString(), intf);
+        }
+
+        // Check whether the prefix length takes effect
+        InetAddress nextHopAddress = InetAddresses.forString("192.0.0.1");
+        assertNotNull(nextHopAddress);
+        assertNull(longestInterfacePrefixMatch(nextHopAddress));
+
+        // Check whether it returns the longest matchable address
+        nextHopAddress = InetAddresses.forString("192.168.60.101");
+        assertEquals("sw6-eth1", longestInterfacePrefixMatch(nextHopAddress).getName());
+    }
+}
diff --git a/src/test/java/net/onrc/onos/apps/sdnip/SdnIpTest.java b/src/test/java/net/onrc/onos/apps/sdnip/SdnIpTest.java
index f5a6d31..febddca 100644
--- a/src/test/java/net/onrc/onos/apps/sdnip/SdnIpTest.java
+++ b/src/test/java/net/onrc/onos/apps/sdnip/SdnIpTest.java
@@ -1,81 +1,441 @@
 package net.onrc.onos.apps.sdnip;
 
-import static org.junit.Assert.*;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.reportMatcher;
+import static org.easymock.EasyMock.reset;
+import static org.easymock.EasyMock.verify;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
 
-import java.io.IOException;
 import java.net.InetAddress;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.Iterator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 
+import net.floodlightcontroller.core.module.FloodlightModuleContext;
+import net.floodlightcontroller.core.module.FloodlightModuleException;
+import net.floodlightcontroller.util.MACAddress;
+import net.onrc.onos.api.newintent.IntentId;
+import net.onrc.onos.api.newintent.IntentService;
+import net.onrc.onos.api.newintent.MultiPointToSinglePointIntent;
+import net.onrc.onos.apps.proxyarp.IProxyArpService;
+import net.onrc.onos.apps.sdnip.RibUpdate.Operation;
+import net.onrc.onos.core.matchaction.action.ModifyDstMacAction;
+import net.onrc.onos.core.matchaction.match.PacketMatch;
+import net.onrc.onos.core.matchaction.match.PacketMatchBuilder;
+import net.onrc.onos.core.registry.IControllerRegistryService;
+import net.onrc.onos.core.util.IPv4;
+import net.onrc.onos.core.util.IdBlock;
+import net.onrc.onos.core.util.IntegrationTest;
+import net.onrc.onos.core.util.SwitchPort;
+import net.onrc.onos.core.util.TestUtils;
+
+import org.easymock.IAnswer;
+import org.easymock.IArgumentMatcher;
+import org.junit.Before;
 import org.junit.Test;
+import org.junit.experimental.categories.Category;
 
-import com.googlecode.concurrenttrees.radix.node.concrete.DefaultByteArrayNodeFactory;
-import com.googlecode.concurrenttrees.radixinverted.ConcurrentInvertedRadixTree;
-import com.googlecode.concurrenttrees.radixinverted.InvertedRadixTree;
+import com.google.common.net.InetAddresses;
 
+/**
+ * Integration tests for the SDN-IP application.
+ * <p/>
+ * The tests are very coarse-grained. They feed route updates in to SDN-IP
+ * (simulating routes learnt from BGPd), then they check that the correct
+ * intents are created and submitted to the intent service. The entire route
+ * processing logic of SDN-IP is tested.
+ */
+@Category(IntegrationTest.class)
 public class SdnIpTest {
+    private static final int MAC_ADDRESS_LENGTH = 6;
+    private static final InetAddress ROUTER_ID =
+            InetAddresses.forString("192.168.10.101");
+
+    private static final int MIN_PREFIX_LENGTH = 1;
+    private static final int MAX_PREFIX_LENGTH = 32;
+
+    private SdnIp sdnip;
+    private IProxyArpService proxyArp;
+    private IntentService intentService;
 
     private Map<String, Interface> interfaces;
-    private InvertedRadixTree<Interface> interfaceRoutes;
+    private Map<InetAddress, BgpPeer> peers;
 
-    private Interface longestInterfacePrefixMatch(InetAddress address) {
-        Prefix prefixToSearchFor = new Prefix(address.getAddress(),
-                Prefix.MAX_PREFIX_LENGTH);
-        Iterator<Interface> it =
-                interfaceRoutes.getValuesForKeysPrefixing(
-                        prefixToSearchFor.toBinaryString()).iterator();
-        Interface intf = null;
-        // Find the last prefix, which will be the longest prefix
-        while (it.hasNext()) {
-            intf = it.next();
+    private Random random;
+
+    @Before
+    public void setUp() throws Exception {
+        interfaces = setUpInterfaces();
+        peers = setUpPeers();
+        random = new Random();
+        initSdnIp();
+    }
+
+    private Map<String, Interface> setUpInterfaces() {
+        Map<String, Interface> configuredInterfaces = new HashMap<>();
+
+        String name1 = "s1-eth1";
+        configuredInterfaces.put(name1, new Interface(name1, "00:00:00:00:00:00:00:01",
+                (short) 1, "192.168.10.101", 24));
+        String name2 = "s2-eth1";
+        configuredInterfaces.put(name2, new Interface(name2, "00:00:00:00:00:00:00:02",
+                (short) 1, "192.168.20.101", 24));
+        String name3 = "s3-eth1";
+        configuredInterfaces.put(name3, new Interface(name3, "00:00:00:00:00:00:00:03",
+                (short) 1, "192.168.30.101", 24));
+
+        return configuredInterfaces;
+    }
+
+    private Map<InetAddress, BgpPeer> setUpPeers() {
+        Map<InetAddress, BgpPeer> configuredPeers = new LinkedHashMap<>();
+
+        String peer1 = "192.168.10.1";
+        configuredPeers.put(InetAddresses.forString(peer1),
+                new BgpPeer("s1-eth1", peer1));
+
+        String peer2 = "192.168.20.1";
+        configuredPeers.put(InetAddresses.forString(peer2),
+                new BgpPeer("s2-eth1", peer2));
+
+        String peer3 = "192.168.30.1";
+        configuredPeers.put(InetAddresses.forString(peer3),
+                new BgpPeer("s3-eth1", peer3));
+
+        return configuredPeers;
+    }
+
+    private void initSdnIp() throws FloodlightModuleException {
+        sdnip = new SdnIp();
+
+        FloodlightModuleContext context = new FloodlightModuleContext();
+        context.addConfigParam(sdnip, "BgpdRestIp", "1.1.1.1");
+        context.addConfigParam(sdnip, "RouterId", "192.168.10.101");
+
+        intentService = createMock(IntentService.class);
+        replay(intentService);
+
+        proxyArp = new TestProxyArpService();
+        context.addService(IProxyArpService.class, proxyArp);
+
+        IControllerRegistryService registry =
+                createMock(IControllerRegistryService.class);
+        expect(registry.allocateUniqueIdBlock()).
+                andReturn(new IdBlock(0, Long.MAX_VALUE));
+        replay(registry);
+        context.addService(IControllerRegistryService.class, registry);
+
+        TestUtils.setField(sdnip, "intentService", intentService);
+        sdnip.init(context);
+        TestUtils.setField(sdnip, "interfaces", interfaces);
+        TestUtils.setField(sdnip, "bgpPeers", peers);
+
+    }
+
+    /**
+     * EasyMock matcher that matches {@link MultiPointToSinglePointIntent}s but
+     * ignores the {@link IntentId} when matching.
+     * <p/>
+     * The normal intent equals method tests that the intent IDs are equal,
+     * however in these tests I can't know what the intent IDs will be in
+     * advance, so I can't set up expected intents with the correct IDs. Even
+     * though I can set up an ID generator that generates a sequential stream
+     * of IDs, I don't know in which order the IDs will be assigned to intents
+     * (because intents can be processed out of order due to timing randomness
+     * introduced by the test ARP module).
+     * <p/>
+     * The solution is to use an EasyMock matcher that verifies that all the
+     * value properties of the provided intent match the expected values, but
+     * ignores the intent ID when testing equality.
+     */
+    private static final class IdAgnosticIntentMatcher implements IArgumentMatcher {
+        private final MultiPointToSinglePointIntent intent;
+        private String providedIntentString;
+
+        /**
+         * Constructor taking the expected intent to match against.
+         *
+         * @param intent the expected intent
+         */
+        public IdAgnosticIntentMatcher(MultiPointToSinglePointIntent intent) {
+            this.intent = intent;
         }
 
-        return intf;
+        @Override
+        public void appendTo(StringBuffer strBuffer) {
+            strBuffer.append("IntentMatcher unable to match: " + providedIntentString);
+        }
+
+        @Override
+        public boolean matches(Object object) {
+            if (!(object instanceof MultiPointToSinglePointIntent)) {
+                return false;
+            }
+
+            MultiPointToSinglePointIntent providedIntent =
+                    (MultiPointToSinglePointIntent) object;
+            providedIntentString = providedIntent.toString();
+
+            MultiPointToSinglePointIntent matchIntent =
+                    new MultiPointToSinglePointIntent(providedIntent.getId(),
+                    intent.getMatch(), intent.getAction(), intent.getIngressPorts(),
+                    intent.getEgressPort());
+
+            return matchIntent.equals(providedIntent);
+        }
     }
+
     /**
-     * This is just a test of the InvertedRadixTree, rather than an actual unit
-     * test of SdnIp.
+     * Matcher method to set an expected intent to match against (ignoring the
+     * the intent ID).
      *
-     * @throws IOException
+     * @param intent the expected intent
+     * @return something of type MultiPointToSinglePointIntent
+     */
+    private static MultiPointToSinglePointIntent eqExceptId(
+            MultiPointToSinglePointIntent intent) {
+        reportMatcher(new IdAgnosticIntentMatcher(intent));
+        return null;
+    }
+
+    /**
+     * Tests adding a set of routes into SDN-IP.
+     * <p/>
+     * Random routes are generated and fed in to the SDN-IP route processing
+     * logic (via processRibAdd). We check that the correct intents are
+     * generated and submitted to our mock intent service.
+     *
+     * @throws InterruptedException if interrupted while waiting on a latch
      */
     @Test
-    public void getOutgoingInterfaceTest() throws IOException {
+    public void testAddRoutes() throws InterruptedException {
+        int numRoutes = 100;
 
-        interfaces = new HashMap<>();
-        interfaceRoutes = new ConcurrentInvertedRadixTree<>(
-                new DefaultByteArrayNodeFactory());
+        final CountDownLatch latch = new CountDownLatch(numRoutes);
 
-        Interface interface1 = new Interface("sw3-eth1", "00:00:00:00:00:00:00:a3",
-                (short) 1, "192.168.10.101", 24);
-        interfaces.put(interface1.getName(), interface1);
-        Interface interface2 = new Interface("sw5-eth1", "00:00:00:00:00:00:00:a5",
-                (short) 1, "192.168.20.101", 16);
-        interfaces.put(interface2.getName(), interface2);
-        Interface interface3 = new Interface("sw2-eth1", "00:00:00:00:00:00:00:a2",
-                (short) 1, "192.168.60.101", 16);
-        interfaces.put(interface3.getName(), interface3);
-        Interface interface4 = new Interface("sw6-eth1", "00:00:00:00:00:00:00:a6",
-                (short) 1, "192.168.60.101", 30);
-        interfaces.put(interface4.getName(), interface4);
-        Interface interface5 = new Interface("sw4-eth4", "00:00:00:00:00:00:00:a4",
-                (short) 4, "192.168.60.101", 24);
-        interfaces.put(interface5.getName(), interface5);
+        List<RibUpdate> routeUpdates = generateRouteUpdateIntents(numRoutes);
 
-        for (Interface intf : interfaces.values()) {
-            Prefix prefix = new Prefix(intf.getIpAddress().getAddress(),
-                    intf.getPrefixLength());
-            interfaceRoutes.put(prefix.toBinaryString(), intf);
+        reset(intentService);
+
+        // Set up expectations
+        for (RibUpdate update : routeUpdates) {
+            InetAddress nextHopPeer = update.getRibEntry().getNextHop();
+            MultiPointToSinglePointIntent intent = getIntentForUpdate(update,
+                    generateMacAddress(nextHopPeer),
+                    interfaces.get(peers.get(nextHopPeer).getInterfaceName()));
+            intentService.submit(eqExceptId(intent));
+            expectLastCall().andAnswer(new IAnswer<Object>() {
+                @Override
+                public Object answer() throws Throwable {
+                    latch.countDown();
+                    return null;
+                }
+            }).once();
         }
 
-        // Check whether the prefix length takes effect
-        InetAddress nextHopAddress = InetAddress.getByName("192.0.0.1");
-        assertNotNull(nextHopAddress);
-        assertNull(longestInterfacePrefixMatch(nextHopAddress));
+        replay(intentService);
 
-        // Check whether it returns the longest matchable address
-        nextHopAddress = InetAddress.getByName("192.168.60.101");
-        assertEquals("sw6-eth1", longestInterfacePrefixMatch(nextHopAddress).getName());
+        // Add route updates
+        for (RibUpdate update : routeUpdates) {
+            sdnip.processRibAdd(update);
+        }
 
+        latch.await(5000, TimeUnit.MILLISECONDS);
+
+        assertEquals(sdnip.getPtree().size(), numRoutes);
+
+        verify(intentService);
     }
+
+    /**
+     * Tests adding then deleting a set of routes from SDN-IP.
+     * <p/>
+     * Random routes are generated and fed in to the SDN-IP route processing
+     * logic (via processRibAdd), and we check that the correct intents are
+     * generated. We then delete the entire set of routes (by feeding updates
+     * to processRibDelete), and check that the correct intents are withdrawn
+     * from the intent service.
+     *
+     * @throws InterruptedException if interrupted while waiting on a latch
+     */
+    @Test
+    public void testDeleteRoutes() throws InterruptedException {
+        int numRoutes = 100;
+        List<RibUpdate> routeUpdates = generateRouteUpdateIntents(numRoutes);
+
+        final CountDownLatch installCount = new CountDownLatch(numRoutes);
+        final CountDownLatch deleteCount = new CountDownLatch(numRoutes);
+
+        reset(intentService);
+
+        for (RibUpdate update : routeUpdates) {
+            InetAddress nextHopPeer = update.getRibEntry().getNextHop();
+            MultiPointToSinglePointIntent intent = getIntentForUpdate(update,
+                    generateMacAddress(nextHopPeer),
+                    interfaces.get(peers.get(nextHopPeer).getInterfaceName()));
+            intentService.submit(eqExceptId(intent));
+            expectLastCall().andAnswer(new IAnswer<Object>() {
+                @Override
+                public Object answer() throws Throwable {
+                    installCount.countDown();
+                    return null;
+                }
+            }).once();
+            intentService.withdraw(eqExceptId(intent));
+            expectLastCall().andAnswer(new IAnswer<Object>() {
+                @Override
+                public Object answer() throws Throwable {
+                    deleteCount.countDown();
+                    return null;
+                }
+            }).once();
+        }
+
+        replay(intentService);
+
+
+        // Send the add updates first
+        for (RibUpdate update : routeUpdates) {
+            sdnip.processRibAdd(update);
+        }
+
+        // Give some time to let the intents be submitted
+        installCount.await(5000, TimeUnit.MILLISECONDS);
+
+        // Send the DELETE updates
+        for (RibUpdate update : routeUpdates) {
+            RibUpdate deleteUpdate = new RibUpdate(Operation.DELETE,
+                    update.getPrefix(), update.getRibEntry());
+            sdnip.processRibDelete(deleteUpdate);
+        }
+
+        deleteCount.await(5000, TimeUnit.MILLISECONDS);
+
+        assertEquals(0, sdnip.getPtree().size());
+
+        verify(intentService);
+    }
+
+    /**
+     * Generates a set of route updates. The prefix for each route is randomly
+     * generated, and the next hop is selected from the set of BGP peers that
+     * was generated during {@link #setUp()}. All have the UPDATE operation.
+     * The generated prefixes are unique within the batch generated by each
+     * call of this method.
+     *
+     * @param numRoutes the number of route updates to generate
+     * @return a list of generated route updates
+     */
+    private List<RibUpdate> generateRouteUpdateIntents(int numRoutes) {
+        List<RibUpdate> routeUpdates = new ArrayList<>(numRoutes);
+
+        Set<Prefix> prefixes = new HashSet<>();
+
+        for (int i = 0; i < numRoutes; i++) {
+            Prefix prefix;
+            do {
+                InetAddress prefixAddress = InetAddresses.fromInteger(random.nextInt());
+                // Generate a random prefix length between MIN_PREFIX_LENGTH and
+                // MAX_PREFIX_LENGTH
+                int prefixLength = random.nextInt(
+                        (MAX_PREFIX_LENGTH - MIN_PREFIX_LENGTH) + 1) + MIN_PREFIX_LENGTH;
+                prefix = new Prefix(prefixAddress.getAddress(), prefixLength);
+                // We have to ensure we don't generate the same prefix twice
+                // (this is quite easy to do with small prefix lengths)
+            } while (prefixes.contains(prefix));
+
+            prefixes.add(prefix);
+
+            // Randomly select a peer to use as the next hop
+            BgpPeer nextHop = null;
+            int peerNumber = random.nextInt(peers.size());
+            int j = 0;
+            for (BgpPeer peer : peers.values()) {
+                if (j++ == peerNumber) {
+                    nextHop = peer;
+                    break;
+                }
+            }
+
+            assertNotNull(nextHop);
+
+            RibUpdate update = new RibUpdate(Operation.UPDATE, prefix,
+                    new RibEntry(ROUTER_ID, nextHop.getIpAddress()));
+
+            routeUpdates.add(update);
+        }
+
+        return routeUpdates;
+    }
+
+    /**
+     * Generates the MultiPointToSinglePointIntent that should be
+     * submitted/withdrawn for a particular RibUpdate.
+     *
+     * @param update the RibUpdate to generate an intent for
+     * @param nextHopMac a MAC address to use as the dst-mac for the intent
+     * @param egressInterface the outgoing interface for the intent
+     * @return the generated intent
+     */
+    private MultiPointToSinglePointIntent getIntentForUpdate(RibUpdate update,
+            MACAddress nextHopMac, Interface egressInterface) {
+        Prefix prefix = update.getPrefix();
+
+        PacketMatchBuilder builder = new PacketMatchBuilder();
+        builder.setDstIp(new IPv4(
+                InetAddresses.coerceToInteger(prefix.getInetAddress())),
+                (short) prefix.getPrefixLength());
+        PacketMatch match = builder.build();
+
+        ModifyDstMacAction action = new ModifyDstMacAction(nextHopMac);
+
+        Set<SwitchPort> ingressPorts = new HashSet<>();
+        for (Interface intf : interfaces.values()) {
+            if (!intf.equals(egressInterface)) {
+                ingressPorts.add(intf.getSwitchPort());
+            }
+        }
+
+        // Create the intent. The intent ID is arbitrary because we don't consider
+        // it when matching against the intent passed to the mock
+        MultiPointToSinglePointIntent intent = new MultiPointToSinglePointIntent(
+                new IntentId(0), match, action, ingressPorts,
+                egressInterface.getSwitchPort());
+
+        return intent;
+    }
+
+    /**
+     * Generates a MAC address based on an IP address.
+     * For the test we need MAC addresses but the actual values don't have any
+     * meaning, so we'll just generate them based on the IP address. This means
+     * we have a deterministic mapping from IP address to MAC address.
+     *
+     * @param ipAddress IP address used to generate a MAC address
+     * @return generated MAC address
+     */
+    public static MACAddress generateMacAddress(InetAddress ipAddress) {
+        byte[] macAddress = new byte[MAC_ADDRESS_LENGTH];
+        ByteBuffer bb = ByteBuffer.wrap(macAddress);
+
+        // Put the IP address bytes into the lower four bytes of the MAC address.
+        // Leave the first two bytes set to 0.
+        bb.position(2);
+        bb.put(ipAddress.getAddress());
+
+        return MACAddress.valueOf(bb.array());
+    }
+
 }
diff --git a/src/test/java/net/onrc/onos/apps/sdnip/TestProxyArpService.java b/src/test/java/net/onrc/onos/apps/sdnip/TestProxyArpService.java
new file mode 100644
index 0000000..d6129ac
--- /dev/null
+++ b/src/test/java/net/onrc/onos/apps/sdnip/TestProxyArpService.java
@@ -0,0 +1,108 @@
+package net.onrc.onos.apps.sdnip;
+
+import java.net.InetAddress;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import net.floodlightcontroller.util.MACAddress;
+import net.onrc.onos.apps.proxyarp.IArpRequester;
+import net.onrc.onos.apps.proxyarp.IProxyArpService;
+
+/**
+ * Test version of the IProxyArpService which is used to simulate delays in
+ * receiving ARP replies, as you would see in a real system due to the time
+ * it takes to proxy ARP packets to/from the host. Requests are asynchronous,
+ * and replies may come back to the requestor in a different order than the
+ * requests were sent, which again you would expect to see in a real system.
+ */
+public class TestProxyArpService implements IProxyArpService {
+
+    /**
+     * The maximum possible delay before an ARP reply is received.
+     */
+    private static final int MAX_ARP_REPLY_DELAY = 30; // milliseconds
+
+    /**
+     * The probability that we already have the MAC address cached when the
+     * caller calls {@link #getMacAddress(InetAddress)}.
+     */
+    private static final float MAC_ALREADY_KNOWN_PROBABILITY = 0.3f;
+
+    private final ScheduledExecutorService replyTaskExecutor;
+
+    private final Random random;
+
+    /**
+     * Class constructor.
+     */
+    public TestProxyArpService() {
+        replyTaskExecutor = Executors.newSingleThreadScheduledExecutor();
+        random = new Random();
+    }
+
+    /**
+     * Task used to reply to ARP requests from a different thread. Replies
+     * usually come on a different thread in the real system, so we need to
+     * ensure we test this behaviour.
+     */
+    private static class ReplyTask implements Runnable {
+        private IArpRequester requestor;
+        private InetAddress ipAddress;
+        private MACAddress macAddress;
+
+        /**
+         * Class constructor.
+         *
+         * @param requestor the client who requested the MAC address
+         * @param ipAddress the target IP address of the request
+         * @param macAddress the MAC address in the ARP reply
+         */
+        public ReplyTask(IArpRequester requestor, InetAddress ipAddress,
+                MACAddress macAddress) {
+            this.requestor = requestor;
+            this.ipAddress = ipAddress;
+            this.macAddress = macAddress;
+        }
+
+        @Override
+        public void run() {
+            requestor.arpResponse(ipAddress, macAddress);
+        }
+    }
+
+    @Override
+    public MACAddress getMacAddress(InetAddress ipAddress) {
+        float replyChance = random.nextFloat();
+        if (replyChance < MAC_ALREADY_KNOWN_PROBABILITY) {
+            // Some percentage of the time we already know the MAC address, so
+            // we reply directly when the requestor asks for the MAC address
+            return SdnIpTest.generateMacAddress(ipAddress);
+        }
+        return null;
+    }
+
+    @Override
+    public void sendArpRequest(InetAddress ipAddress, IArpRequester requester,
+            boolean retry) {
+        // Randomly select an amount of time to delay the reply coming back to
+        // the requestor (simulating time taken to proxy the request to a
+        // network host).
+        int delay = random.nextInt(MAX_ARP_REPLY_DELAY);
+
+        MACAddress macAddress = SdnIpTest.generateMacAddress(ipAddress);
+
+        ReplyTask replyTask = new ReplyTask(requester, ipAddress, macAddress);
+
+        replyTaskExecutor.schedule(replyTask , delay, TimeUnit.MILLISECONDS);
+    }
+
+    @Override
+    public List<String> getMappings() {
+        // We don't care about this method for the current test use cases
+        return null;
+    }
+
+}