Closes #146

Unit test AbstractNonProvisionedController
diff --git a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerImpl.java b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerImpl.java
index 7ad7224..15cd94d 100644
--- a/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerImpl.java
+++ b/software/webapp/src/main/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerImpl.java
@@ -137,6 +137,9 @@
         sensors().set(MAIN_URI, URI.create(inferUrl()));
         sensors().set(ROOT_URL, inferUrl());
         addServerPoolMemberTrackingPolicy();
+        
+        isActive = true;
+        update();
     }
     
     protected void preStop() {
@@ -273,7 +276,6 @@
         }
     }
 
-    
     protected void onServerPoolMemberChanged(Entity member) {
         synchronized (mutex) {
             if (LOG.isTraceEnabled()) LOG.trace("For {}, considering membership of {} which is in locations {}", 
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractAbstractControllerTest.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractAbstractControllerTest.java
new file mode 100644
index 0000000..eba82c5
--- /dev/null
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractAbstractControllerTest.java
@@ -0,0 +1,318 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.brooklyn.entity.proxy;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+import java.net.Inet4Address;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.brooklyn.api.entity.Entity;
+import org.apache.brooklyn.api.entity.EntitySpec;
+import org.apache.brooklyn.api.location.Location;
+import org.apache.brooklyn.api.location.LocationSpec;
+import org.apache.brooklyn.api.location.MachineLocation;
+import org.apache.brooklyn.api.location.MachineProvisioningLocation;
+import org.apache.brooklyn.api.location.NoMachinesAvailableException;
+import org.apache.brooklyn.api.sensor.AttributeSensor;
+import org.apache.brooklyn.core.entity.Attributes;
+import org.apache.brooklyn.core.entity.trait.Startable;
+import org.apache.brooklyn.core.location.HasSubnetHostname;
+import org.apache.brooklyn.core.location.Machines;
+import org.apache.brooklyn.core.test.BrooklynAppUnitTestSupport;
+import org.apache.brooklyn.core.test.entity.TestEntity;
+import org.apache.brooklyn.core.test.entity.TestEntityImpl;
+import org.apache.brooklyn.entity.group.Cluster;
+import org.apache.brooklyn.entity.group.DynamicCluster;
+import org.apache.brooklyn.location.byon.FixedListMachineProvisioningLocation;
+import org.apache.brooklyn.location.ssh.SshMachineLocation;
+import org.apache.brooklyn.test.Asserts;
+import org.apache.brooklyn.util.collections.MutableMap;
+import org.apache.brooklyn.util.collections.MutableSet;
+import org.apache.brooklyn.util.core.flags.SetFromFlag;
+import org.apache.brooklyn.util.exceptions.Exceptions;
+import org.apache.brooklyn.util.guava.Maybe;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+
+/**
+ * Sub-classes are concrete tests of {@link AbstractController} and {@link AbstractNonProvisionedController},
+ * hence the weird double-abstract name!
+ */
+public abstract class AbstractAbstractControllerTest<T extends LoadBalancer> extends BrooklynAppUnitTestSupport {
+
+    private static final Logger log = LoggerFactory.getLogger(AbstractAbstractControllerTest.class);
+    
+    protected FixedListMachineProvisioningLocation<?> loc;
+    protected Cluster cluster;
+    protected T controller;
+    
+    @BeforeMethod(alwaysRun = true)
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        
+        List<SshMachineLocation> machines = new ArrayList<SshMachineLocation>();
+        for (int i = 1; i <= 10; i++) {
+            SshMachineLocation machine = mgmt.getLocationManager().createLocation(LocationSpec.create(SshMachineLocation.class)
+                    .configure("address", Inet4Address.getByName("1.1.1."+i)));
+            machines.add(machine);
+        }
+        loc = mgmt.getLocationManager().createLocation(LocationSpec.create(FixedListMachineProvisioningLocation.class)
+                .configure("machines", machines));
+        
+        cluster = app.addChild(EntitySpec.create(DynamicCluster.class)
+                .configure("initialSize", 0)
+                .configure("memberSpec", EntitySpec.create(TestEntity.class).impl(WebServerEntity.class)));
+        
+        controller = newController();
+        
+        app.start(ImmutableList.of(loc));
+    }
+    
+    /**
+     * Called during {@link #setUp()}, after app and cluster are created (but before app.start is called).
+     */
+    protected abstract T newController();
+    
+    protected abstract List<Collection<String>> getUpdates(T controller);
+    
+    // Fixes bug where entity that wrapped an AS7 entity was never added to nginx because hostname+port
+    // was set after service_up. Now we listen to those changes and reset the nginx pool when these
+    // values change.
+    @Test
+    public void testUpdateCalledWhenChildHostnameAndPortChanges() throws Exception {
+        log.info("adding child (no effect until up)");
+        TestEntity child = cluster.addChild(EntitySpec.create(TestEntity.class));
+        cluster.addMember(child);
+
+        List<Collection<String>> u = Lists.newArrayList(getUpdates(controller));
+        assertTrue(u.isEmpty(), "expected no updates, but got "+u);
+        
+        log.info("setting child service_up");
+        child.sensors().set(Startable.SERVICE_UP, true);
+        // above may trigger error logged about no hostname, but should update again with the settings below
+        
+        log.info("setting mymachine:1234");
+        child.sensors().set(WebServerEntity.HOSTNAME, "mymachine");
+        child.sensors().set(Attributes.SUBNET_HOSTNAME, "mymachine");
+        child.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine:1234"));
+        
+        /* a race failure has been observed, https://issues.apache.org/jira/browse/BROOKLYN-206
+         * but now (two months later) i (alex) can't see how it could happen. 
+         * probably optimistic but maybe it is fixed. if not we'll need the debug logs to see what is happening.
+         * i've confirmed:
+         * * the policy is attached and active during setup, before start completes
+         * * the child is added as a member synchronously
+         * * the policy which is "subscribed to members" is in fact subscribed to everything
+         *   then filtered for members, not ideal, but there shouldn't be a race in the policy getting notices
+         * * the handling of those events are both processed in order and look up the current values
+         *   rather than relying on the published values; either should be sufficient to cause the addresses to change
+         * there was a sleep(100) marked "Ugly sleep to allow AbstractController to detect node having been added"
+         * from the test's addition by aled in early 2014, but can't see why that would be necessary
+         */
+        
+        log.info("setting mymachine2:1234");
+        child.sensors().set(WebServerEntity.HOSTNAME, "mymachine2");
+        child.sensors().set(Attributes.SUBNET_HOSTNAME, "mymachine2");
+        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine2:1234"));
+        
+        log.info("setting mymachine2:1235");
+        child.sensors().set(WebServerEntity.HTTP_PORT, 1235);
+        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine2:1235"));
+        
+        log.info("clearing");
+        child.sensors().set(WebServerEntity.HOSTNAME, null);
+        child.sensors().set(Attributes.SUBNET_HOSTNAME, null);
+        assertEventuallyExplicitAddressesMatch(ImmutableList.<String>of());
+    }
+
+    @Test
+    public void testUpdateCalledWithAddressesOfNewChildren() {
+        // First child
+        cluster.resize(1);
+        Entity child = Iterables.getOnlyElement(cluster.getMembers());
+        
+        List<Collection<String>> u = Lists.newArrayList(getUpdates(controller));
+        assertTrue(u.isEmpty(), "expected empty list but got "+u);
+        
+        child.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+        child.sensors().set(Startable.SERVICE_UP, true);
+        assertEventuallyAddressesMatchCluster();
+
+        // Second child
+        cluster.resize(2);
+        Asserts.succeedsEventually(new Runnable() {
+            @Override
+            public void run() {
+                assertEquals(cluster.getMembers().size(), 2);
+            }});
+        Entity child2 = Iterables.getOnlyElement(MutableSet.<Entity>builder().addAll(cluster.getMembers()).remove(child).build());
+        
+        child2.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+        child2.sensors().set(Startable.SERVICE_UP, true);
+        assertEventuallyAddressesMatchCluster();
+        
+        // And remove all children; expect all addresses to go away
+        cluster.resize(0);
+        assertEventuallyAddressesMatchCluster();
+    }
+
+    @Test(groups = "Integration", invocationCount=10)
+    public void testUpdateCalledWithAddressesOfNewChildrenManyTimes() {
+        testUpdateCalledWithAddressesOfNewChildren();
+    }
+    
+    @Test
+    public void testUpdateCalledWithAddressesRemovedForStoppedChildren() {
+        // Get some children, so we can remove one...
+        cluster.resize(2);
+        for (Entity it: cluster.getMembers()) { 
+            it.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+            it.sensors().set(Startable.SERVICE_UP, true);
+        }
+        assertEventuallyAddressesMatchCluster();
+
+        // Now remove one child
+        cluster.resize(1);
+        assertEquals(cluster.getMembers().size(), 1);
+        assertEventuallyAddressesMatchCluster();
+    }
+
+    @Test
+    public void testUpdateCalledWithAddressesRemovedForServiceDownChildrenThatHaveClearedHostnamePort() {
+        // Get some children, so we can remove one...
+        cluster.resize(2);
+        for (Entity it: cluster.getMembers()) { 
+            it.sensors().set(WebServerEntity.HTTP_PORT, 1234);
+            it.sensors().set(Startable.SERVICE_UP, true);
+        }
+        assertEventuallyAddressesMatchCluster();
+
+        // Now unset host/port, and remove children
+        // Note the unsetting of hostname is done in SoftwareProcessImpl.stop(), so this is realistic
+        for (Entity it : cluster.getMembers()) {
+            it.sensors().set(WebServerEntity.HTTP_PORT, null);
+            it.sensors().set(WebServerEntity.HOSTNAME, null);
+            it.sensors().set(Startable.SERVICE_UP, false);
+        }
+        assertEventuallyAddressesMatch(ImmutableList.<Entity>of());
+    }
+
+    protected void assertEventuallyAddressesMatchCluster() {
+        assertEventuallyAddressesMatchCluster(cluster);
+    }
+
+    protected void assertEventuallyAddressesMatchCluster(Cluster cluster) {
+        assertEventuallyAddressesMatch(cluster.getMembers());
+    }
+
+    protected void assertEventuallyAddressesMatch(final Collection<Entity> expectedMembers) {
+        Asserts.succeedsEventually(new Runnable() {
+                @Override public void run() {
+                    assertAddressesMatch(locationsToAddresses(1234, expectedMembers));
+                }} );
+    }
+
+    protected void assertEventuallyExplicitAddressesMatch(final Collection<String> expectedAddresses) {
+        Asserts.succeedsEventually(new Runnable() {
+            @Override public void run() {
+                assertAddressesMatch(expectedAddresses);
+            }} );
+    }
+
+    protected void assertAddressesMatch(final Collection<String> expectedAddresses) {
+        List<Collection<String>> u = Lists.newArrayList(getUpdates(controller));
+        Collection<String> last = Iterables.getLast(u, null);
+        log.debug("test "+u.size()+" updates, expecting "+expectedAddresses+"; actual "+last);
+        assertTrue(!u.isEmpty(), "no updates; expecting "+expectedAddresses);
+        assertEquals(ImmutableSet.copyOf(last), ImmutableSet.copyOf(expectedAddresses), "actual="+last+" expected="+expectedAddresses);
+        assertEquals(last.size(), expectedAddresses.size(), "actual="+last+" expected="+expectedAddresses);
+    }
+
+    protected Collection<String> locationsToAddresses(int port, Collection<Entity> entities) {
+        Set<String> result = MutableSet.of();
+        for (Entity e : entities) {
+            SshMachineLocation machine = Machines.findUniqueMachineLocation(e.getLocations(), SshMachineLocation.class).get();
+            result.add(machine.getAddress().getHostName()+":"+port);
+        }
+        return result;
+    }
+
+    public static class SshMachineLocationWithSubnetHostname extends SshMachineLocation implements HasSubnetHostname {
+        @Override public String getSubnetHostname() {
+            return getSubnetIp();
+        }
+        @Override public String getSubnetIp() {
+            Set<String> addrs = getPrivateAddresses();
+            return (addrs.isEmpty()) ? getAddress().getHostAddress() : Iterables.get(addrs, 0);
+        }
+    }
+    
+    public static class WebServerEntity extends TestEntityImpl {
+        @SetFromFlag("hostname")
+        public static final AttributeSensor<String> HOSTNAME = Attributes.HOSTNAME;
+        
+        @SetFromFlag("port")
+        public static final AttributeSensor<Integer> HTTP_PORT = Attributes.HTTP_PORT;
+        
+        @SetFromFlag("hostAndPort")
+        public static final AttributeSensor<String> HOST_AND_PORT = Attributes.HOST_AND_PORT;
+        
+        MachineProvisioningLocation<MachineLocation> provisioner;
+        
+        @Override
+        public void start(Collection<? extends Location> locs) {
+            provisioner = (MachineProvisioningLocation<MachineLocation>) locs.iterator().next();
+            MachineLocation machine;
+            try {
+                machine = provisioner.obtain(MutableMap.of());
+            } catch (NoMachinesAvailableException e) {
+                throw Exceptions.propagate(e);
+            }
+            addLocations(Arrays.asList(machine));
+            sensors().set(HOSTNAME, machine.getAddress().getHostName());
+            sensors().set(Attributes.SUBNET_HOSTNAME, machine.getAddress().getHostName());
+            sensors().set(Attributes.MAIN_URI_MAPPED_SUBNET, URI.create(machine.getAddress().getHostName()));
+            sensors().set(Attributes.MAIN_URI_MAPPED_PUBLIC, URI.create("http://8.8.8.8:" + sensors().get(HTTP_PORT)));
+        }
+
+        @Override
+        public void stop() {
+            Maybe<MachineLocation> machine = Machines.findUniqueMachineLocation(getLocations(), MachineLocation.class);
+            if (provisioner != null) {
+                provisioner.release(machine.get());
+            }
+        }
+    }
+}
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractControllerTest.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractControllerTest.java
index ea4b4d8..25627d8 100644
--- a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractControllerTest.java
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractControllerTest.java
@@ -18,210 +18,46 @@
  */
 package org.apache.brooklyn.entity.proxy;
 
-import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertTrue;
 
 import java.net.Inet4Address;
 import java.net.URI;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
-import java.util.Map;
-import java.util.Set;
 
-import org.apache.brooklyn.api.entity.Entity;
 import org.apache.brooklyn.api.entity.EntitySpec;
 import org.apache.brooklyn.api.location.Location;
 import org.apache.brooklyn.api.location.LocationSpec;
-import org.apache.brooklyn.api.location.MachineLocation;
-import org.apache.brooklyn.api.location.MachineProvisioningLocation;
-import org.apache.brooklyn.api.location.NoMachinesAvailableException;
-import org.apache.brooklyn.api.sensor.AttributeSensor;
 import org.apache.brooklyn.core.entity.Attributes;
 import org.apache.brooklyn.core.entity.EntityAsserts;
 import org.apache.brooklyn.core.entity.trait.Startable;
-import org.apache.brooklyn.core.location.HasSubnetHostname;
-import org.apache.brooklyn.core.location.Machines;
 import org.apache.brooklyn.core.location.PortRanges;
-import org.apache.brooklyn.core.test.BrooklynAppUnitTestSupport;
 import org.apache.brooklyn.core.test.entity.TestEntity;
-import org.apache.brooklyn.core.test.entity.TestEntityImpl;
-import org.apache.brooklyn.entity.group.Cluster;
-import org.apache.brooklyn.entity.group.DynamicCluster;
-import org.apache.brooklyn.location.byon.FixedListMachineProvisioningLocation;
 import org.apache.brooklyn.location.ssh.SshMachineLocation;
-import org.apache.brooklyn.test.Asserts;
-import org.apache.brooklyn.util.collections.MutableMap;
-import org.apache.brooklyn.util.collections.MutableSet;
-import org.apache.brooklyn.util.core.flags.SetFromFlag;
 import org.apache.brooklyn.util.exceptions.Exceptions;
-import org.apache.brooklyn.util.guava.Maybe;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 
-public class AbstractControllerTest extends BrooklynAppUnitTestSupport {
+public class AbstractControllerTest extends AbstractAbstractControllerTest<TrackingAbstractController> {
 
     private static final Logger log = LoggerFactory.getLogger(AbstractControllerTest.class);
     
-    FixedListMachineProvisioningLocation<?> loc;
-    Cluster cluster;
-    TrackingAbstractController controller;
-    
-    @BeforeMethod(alwaysRun = true)
     @Override
-    public void setUp() throws Exception {
-        super.setUp();
-        
-        List<SshMachineLocation> machines = new ArrayList<SshMachineLocation>();
-        for (int i = 1; i <= 10; i++) {
-            SshMachineLocation machine = mgmt.getLocationManager().createLocation(LocationSpec.create(SshMachineLocation.class)
-                    .configure("address", Inet4Address.getByName("1.1.1."+i)));
-            machines.add(machine);
-        }
-        loc = mgmt.getLocationManager().createLocation(LocationSpec.create(FixedListMachineProvisioningLocation.class)
-                .configure("machines", machines));
-        
-        cluster = app.addChild(EntitySpec.create(DynamicCluster.class)
-                .configure("initialSize", 0)
-                .configure("memberSpec", EntitySpec.create(TestEntity.class).impl(WebServerEntity.class)));
-        
-        controller = app.addChild(EntitySpec.create(TrackingAbstractController.class)
+    protected TrackingAbstractController newController() {
+        return app.addChild(EntitySpec.create(TrackingAbstractController.class)
                 .configure("serverPool", cluster) 
                 .configure("portNumberSensor", WebServerEntity.HTTP_PORT)
                 .configure("domain", "mydomain"));
-        
-        app.start(ImmutableList.of(loc));
     }
     
-    // Fixes bug where entity that wrapped an AS7 entity was never added to nginx because hostname+port
-    // was set after service_up. Now we listen to those changes and reset the nginx pool when these
-    // values change.
-    @Test
-    public void testUpdateCalledWhenChildHostnameAndPortChanges() throws Exception {
-        log.info("adding child (no effect until up)");
-        TestEntity child = cluster.addChild(EntitySpec.create(TestEntity.class));
-        cluster.addMember(child);
-
-        List<Collection<String>> u = Lists.newArrayList(controller.getUpdates());
-        assertTrue(u.isEmpty(), "expected no updates, but got "+u);
-        
-        log.info("setting child service_up");
-        child.sensors().set(Startable.SERVICE_UP, true);
-        // above may trigger error logged about no hostname, but should update again with the settings below
-        
-        log.info("setting mymachine:1234");
-        child.sensors().set(WebServerEntity.HOSTNAME, "mymachine");
-        child.sensors().set(Attributes.SUBNET_HOSTNAME, "mymachine");
-        child.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine:1234"));
-        
-        /* a race failure has been observed, https://issues.apache.org/jira/browse/BROOKLYN-206
-         * but now (two months later) i (alex) can't see how it could happen. 
-         * probably optimistic but maybe it is fixed. if not we'll need the debug logs to see what is happening.
-         * i've confirmed:
-         * * the policy is attached and active during setup, before start completes
-         * * the child is added as a member synchronously
-         * * the policy which is "subscribed to members" is in fact subscribed to everything
-         *   then filtered for members, not ideal, but there shouldn't be a race in the policy getting notices
-         * * the handling of those events are both processed in order and look up the current values
-         *   rather than relying on the published values; either should be sufficient to cause the addresses to change
-         * there was a sleep(100) marked "Ugly sleep to allow AbstractController to detect node having been added"
-         * from the test's addition by aled in early 2014, but can't see why that would be necessary
-         */
-        
-        log.info("setting mymachine2:1234");
-        child.sensors().set(WebServerEntity.HOSTNAME, "mymachine2");
-        child.sensors().set(Attributes.SUBNET_HOSTNAME, "mymachine2");
-        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine2:1234"));
-        
-        log.info("setting mymachine2:1235");
-        child.sensors().set(WebServerEntity.HTTP_PORT, 1235);
-        assertEventuallyExplicitAddressesMatch(ImmutableList.of("mymachine2:1235"));
-        
-        log.info("clearing");
-        child.sensors().set(WebServerEntity.HOSTNAME, null);
-        child.sensors().set(Attributes.SUBNET_HOSTNAME, null);
-        assertEventuallyExplicitAddressesMatch(ImmutableList.<String>of());
-    }
-
-    @Test
-    public void testUpdateCalledWithAddressesOfNewChildren() {
-        // First child
-        cluster.resize(1);
-        Entity child = Iterables.getOnlyElement(cluster.getMembers());
-        
-        List<Collection<String>> u = Lists.newArrayList(controller.getUpdates());
-        assertTrue(u.isEmpty(), "expected empty list but got "+u);
-        
-        child.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-        child.sensors().set(Startable.SERVICE_UP, true);
-        assertEventuallyAddressesMatchCluster();
-
-        // Second child
-        cluster.resize(2);
-        Asserts.succeedsEventually(new Runnable() {
-            @Override
-            public void run() {
-                assertEquals(cluster.getMembers().size(), 2);
-            }});
-        Entity child2 = Iterables.getOnlyElement(MutableSet.<Entity>builder().addAll(cluster.getMembers()).remove(child).build());
-        
-        child2.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-        child2.sensors().set(Startable.SERVICE_UP, true);
-        assertEventuallyAddressesMatchCluster();
-        
-        // And remove all children; expect all addresses to go away
-        cluster.resize(0);
-        assertEventuallyAddressesMatchCluster();
-    }
-
-    @Test(groups = "Integration", invocationCount=10)
-    public void testUpdateCalledWithAddressesOfNewChildrenManyTimes() {
-        testUpdateCalledWithAddressesOfNewChildren();
-    }
-    
-    @Test
-    public void testUpdateCalledWithAddressesRemovedForStoppedChildren() {
-        // Get some children, so we can remove one...
-        cluster.resize(2);
-        for (Entity it: cluster.getMembers()) { 
-            it.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-            it.sensors().set(Startable.SERVICE_UP, true);
-        }
-        assertEventuallyAddressesMatchCluster();
-
-        // Now remove one child
-        cluster.resize(1);
-        assertEquals(cluster.getMembers().size(), 1);
-        assertEventuallyAddressesMatchCluster();
-    }
-
-    @Test
-    public void testUpdateCalledWithAddressesRemovedForServiceDownChildrenThatHaveClearedHostnamePort() {
-        // Get some children, so we can remove one...
-        cluster.resize(2);
-        for (Entity it: cluster.getMembers()) { 
-            it.sensors().set(WebServerEntity.HTTP_PORT, 1234);
-            it.sensors().set(Startable.SERVICE_UP, true);
-        }
-        assertEventuallyAddressesMatchCluster();
-
-        // Now unset host/port, and remove children
-        // Note the unsetting of hostname is done in SoftwareProcessImpl.stop(), so this is realistic
-        for (Entity it : cluster.getMembers()) {
-            it.sensors().set(WebServerEntity.HTTP_PORT, null);
-            it.sensors().set(WebServerEntity.HOSTNAME, null);
-            it.sensors().set(Startable.SERVICE_UP, false);
-        }
-        assertEventuallyAddressesMatch(ImmutableList.<Entity>of());
+    @Override
+    protected List<Collection<String>> getUpdates(TrackingAbstractController controller) {
+        return controller.getUpdates();
     }
 
     @Test
@@ -333,86 +169,4 @@
         EntityAsserts.assertAttributeEquals(controller2, Attributes.MAIN_URI_MAPPED_PUBLIC, URI.create("http://1.1.1.1:8081/"));
         EntityAsserts.assertAttributeEquals(controller2, Attributes.MAIN_URI_MAPPED_SUBNET, URI.create("http://2.2.2.2:8081/"));
     }
-    public static class SshMachineLocationWithSubnetHostname extends SshMachineLocation implements HasSubnetHostname {
-        @Override public String getSubnetHostname() {
-            return getSubnetIp();
-        }
-        @Override public String getSubnetIp() {
-            Set<String> addrs = getPrivateAddresses();
-            return (addrs.isEmpty()) ? getAddress().getHostAddress() : Iterables.get(addrs, 0);
-        }
-    }
-    
-    private void assertEventuallyAddressesMatchCluster() {
-        assertEventuallyAddressesMatch(cluster.getMembers());
-    }
-
-    private void assertEventuallyAddressesMatch(final Collection<Entity> expectedMembers) {
-        Asserts.succeedsEventually(new Runnable() {
-                @Override public void run() {
-                    assertAddressesMatch(locationsToAddresses(1234, expectedMembers));
-                }} );
-    }
-
-    private void assertEventuallyExplicitAddressesMatch(final Collection<String> expectedAddresses) {
-        Asserts.succeedsEventually(new Runnable() {
-            @Override public void run() {
-                assertAddressesMatch(expectedAddresses);
-            }} );
-    }
-
-    private void assertAddressesMatch(final Collection<String> expectedAddresses) {
-        List<Collection<String>> u = Lists.newArrayList(controller.getUpdates());
-        Collection<String> last = Iterables.getLast(u, null);
-        log.debug("test "+u.size()+" updates, expecting "+expectedAddresses+"; actual "+last);
-        assertTrue(!u.isEmpty(), "no updates; expecting "+expectedAddresses);
-        assertEquals(ImmutableSet.copyOf(last), ImmutableSet.copyOf(expectedAddresses), "actual="+last+" expected="+expectedAddresses);
-        assertEquals(last.size(), expectedAddresses.size(), "actual="+last+" expected="+expectedAddresses);
-    }
-
-    private Collection<String> locationsToAddresses(int port, Collection<Entity> entities) {
-        Set<String> result = MutableSet.of();
-        for (Entity e : entities) {
-            SshMachineLocation machine = Machines.findUniqueMachineLocation(e.getLocations(), SshMachineLocation.class).get();
-            result.add(machine.getAddress().getHostName()+":"+port);
-        }
-        return result;
-    }
-
-    public static class WebServerEntity extends TestEntityImpl {
-        @SetFromFlag("hostname")
-        public static final AttributeSensor<String> HOSTNAME = Attributes.HOSTNAME;
-        
-        @SetFromFlag("port")
-        public static final AttributeSensor<Integer> HTTP_PORT = Attributes.HTTP_PORT;
-        
-        @SetFromFlag("hostAndPort")
-        public static final AttributeSensor<String> HOST_AND_PORT = Attributes.HOST_AND_PORT;
-        
-        MachineProvisioningLocation<MachineLocation> provisioner;
-        
-        @Override
-        public void start(Collection<? extends Location> locs) {
-            provisioner = (MachineProvisioningLocation<MachineLocation>) locs.iterator().next();
-            MachineLocation machine;
-            try {
-                machine = provisioner.obtain(MutableMap.of());
-            } catch (NoMachinesAvailableException e) {
-                throw Exceptions.propagate(e);
-            }
-            addLocations(Arrays.asList(machine));
-            sensors().set(HOSTNAME, machine.getAddress().getHostName());
-            sensors().set(Attributes.SUBNET_HOSTNAME, machine.getAddress().getHostName());
-            sensors().set(Attributes.MAIN_URI_MAPPED_SUBNET, URI.create(machine.getAddress().getHostName()));
-            sensors().set(Attributes.MAIN_URI_MAPPED_PUBLIC, URI.create("http://8.8.8.8:" + sensors().get(HTTP_PORT)));
-        }
-
-        @Override
-        public void stop() {
-            Maybe<MachineLocation> machine = Machines.findUniqueMachineLocation(getLocations(), MachineLocation.class);
-            if (provisioner != null) {
-                provisioner.release(machine.get());
-            }
-        }
-    }
 }
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerTest.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerTest.java
new file mode 100644
index 0000000..88094f4
--- /dev/null
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/AbstractNonProvisionedControllerTest.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.brooklyn.entity.proxy;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.brooklyn.api.entity.EntitySpec;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AbstractNonProvisionedControllerTest extends AbstractAbstractControllerTest<TrackingAbstractNonProvisionedController> {
+
+    // TODO Duplication of AbstractControllerTest
+    
+    private static final Logger log = LoggerFactory.getLogger(AbstractNonProvisionedControllerTest.class);
+    
+    @Override
+    protected TrackingAbstractNonProvisionedController newController() {
+        return app.addChild(EntitySpec.create(TrackingAbstractNonProvisionedController.class)
+                .configure("serverPool", cluster) 
+                .configure("portNumberSensor", WebServerEntity.HTTP_PORT)
+                .configure("domain", "mydomain"));
+    }
+    
+    @Override
+    protected List<Collection<String>> getUpdates(TrackingAbstractNonProvisionedController controller) {
+        return controller.getUpdates();
+    }
+}
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedController.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedController.java
new file mode 100644
index 0000000..f0c6606
--- /dev/null
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedController.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.brooklyn.entity.proxy;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.brooklyn.api.entity.ImplementedBy;
+
+@ImplementedBy(TrackingAbstractNonProvisionedControllerImpl.class)
+public interface TrackingAbstractNonProvisionedController extends AbstractNonProvisionedController {
+    List<Collection<String>> getUpdates();
+}
diff --git a/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedControllerImpl.java b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedControllerImpl.java
new file mode 100644
index 0000000..896e5d6
--- /dev/null
+++ b/software/webapp/src/test/java/org/apache/brooklyn/entity/proxy/TrackingAbstractNonProvisionedControllerImpl.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.brooklyn.entity.proxy;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.brooklyn.api.location.Location;
+import org.apache.brooklyn.util.text.Identifiers;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.collect.Lists;
+
+public class TrackingAbstractNonProvisionedControllerImpl extends AbstractNonProvisionedControllerImpl implements TrackingAbstractNonProvisionedController {
+    
+    private static final Logger log = LoggerFactory.getLogger(TrackingAbstractNonProvisionedControllerImpl.class);
+
+    private final List<Collection<String>> updates = Lists.newCopyOnWriteArrayList();
+
+    @Override
+    public void start(Collection<? extends Location> locations) {
+        sensors().set(HOSTNAME, Identifiers.makeRandomId(8) + ".test.brooklyn.apache.org");
+        super.start(locations);
+    }
+    
+    @Override
+    public List<Collection<String>> getUpdates() {
+        return updates;
+    }
+    
+    @Override
+    protected void reconfigureService() {
+        Set<String> addresses = getServerPoolAddresses();
+        log.info("test controller reconfigure, targets "+addresses);
+        if ((!addresses.isEmpty() && updates.isEmpty()) || (!updates.isEmpty() && addresses != updates.get(updates.size()-1))) {
+            updates.add(addresses);
+        }
+    }
+
+    @Override
+    public void reload() {
+        // no-op
+    }
+
+    @Override
+    public void restart() {
+        // no-op
+    }
+
+    @Override
+    protected String inferProtocol() {
+        String result = config().get(PROTOCOL);
+        return (result == null ? result : result.toLowerCase());
+    }
+
+    @Override
+    protected String inferUrl() {
+        String scheme = inferProtocol();
+        Integer port = sensors().get(PROXY_HTTP_PORT);
+        String domainName = sensors().get(HOSTNAME);
+        return scheme + "://" + domainName + ":" + port;
+    }
+}