Repository: locustio/locust

Issue:
Tests fail to run when providing a small number of clients
Hello,

My current setup in locustfile.py currently looks something like this:
```python
class VolumeTests(object):
      host = 'http://example.com'
      min_wait = 5000
      max_wait = 15000

class ExampleTest(VolumeTests, HttpLocust):
      task_set = MyTaskSet
```
I have 7 of those ExampleTests at the moment, all different types of tests.
In the command line, when I run `locust -c 3 --no-web`, the testing quits and I get the following message:
"Hatching and swarming 0 clients at the rate 1 clients/s..."

However, when I run `locust -c 4 --no-web`, changing the number of clients from 3 to 4, the tests run successfully. 

After some experimenting, it seems like the number of clients you provide must be at least half of the number of tests (i.e. ExampleTest).

Is there something in the locust documentation that I am missing? Or is there something that I am doing wrong? Thanks. 

Discussion:

Patch:
diff --git a/locust/runners.py b/locust/runners.py
index 268f7033..97df507f 100644
--- a/locust/runners.py
+++ b/locust/runners.py
@@ -68,6 +68,7 @@ class LocustRunner(object):
         """
         bucket = []
         weight_sum = sum((locust.weight for locust in self.locust_classes if locust.task_set))
+        residuals = {}
         for locust in self.locust_classes:
             if not locust.task_set:
                 warnings.warn("Notice: Found Locust class (%s) got no task_set. Skipping..." % locust.__name__)
@@ -82,6 +83,21 @@ class LocustRunner(object):
             percent = locust.weight / float(weight_sum)
             num_locusts = int(round(amount * percent))
             bucket.extend([locust for x in xrange(0, num_locusts)])
+            # used to keep track of the amount of rounding was done if we need
+            # to add/remove some instances from bucket
+            residuals[locust] = amount * percent - round(amount * percent)
+        if len(bucket) < amount:
+            # We got too few locust classes in the bucket, so we need to create a few extra locusts,
+            # and we do this by iterating over each of the Locust classes - starting with the one
+            # where the residual from the rounding was the largest - and creating one of each until
+            # we get the correct amount
+            for locust in [l for l, r in sorted(residuals.items(), key=lambda x:x[1], reverse=True)][:amount-len(bucket)]:
+                bucket.append(locust)
+        elif len(bucket) > amount:
+            # We've got too many locusts due to rounding errors so we need to remove some
+            for locust in [l for l, r in sorted(residuals.items(), key=lambda x:x[1])][:len(bucket)-amount]:
+                bucket.remove(locust)
+
         return bucket
 
     def spawn_locusts(self, spawn_count=None, stop_timeout=None, wait=False):


Test patch:
diff --git a/locust/test/test_runners.py b/locust/test/test_runners.py
index 8a51c75c..14ad50f9 100644
--- a/locust/test/test_runners.py
+++ b/locust/test/test_runners.py
@@ -9,10 +9,12 @@ from locust import events
 from locust.core import Locust, TaskSet, task
 from locust.exception import LocustError
 from locust.rpc import Message
-from locust.runners import LocalLocustRunner, MasterLocustRunner, SlaveNode, STATE_INIT, STATE_HATCHING, STATE_RUNNING, STATE_MISSING
+from locust.runners import LocustRunner, LocalLocustRunner, MasterLocustRunner, SlaveNode, \
+     STATE_INIT, STATE_HATCHING, STATE_RUNNING, STATE_MISSING
 from locust.stats import global_stats, RequestStats
 from locust.test.testcases import LocustTestCase
 
+
 def mocked_rpc_server():
     class MockedRpcServer(object):
         queue = Queue()
@@ -43,6 +45,7 @@ def mocked_rpc_server():
 
     return MockedRpcServer
 
+
 class mocked_options(object):
     def __init__(self):
         self.hatch_rate = 5
@@ -58,6 +61,57 @@ class mocked_options(object):
     def reset_stats(self):
         pass
 
+
+class TestLocustRunner(LocustTestCase):
+    def assert_locust_class_distribution(self, expected_distribution, classes):
+        # Construct a {LocustClass => count} dict from a list of locust classes
+        distribution = {}
+        for locust_class in classes:
+            if not locust_class in distribution:
+                distribution[locust_class] = 0
+            distribution[locust_class] += 1
+        expected_str = str({k.__name__:v for k,v in expected_distribution.items()})
+        actual_str = str({k.__name__:v for k,v in distribution.items()})
+        self.assertEqual(
+            expected_distribution,
+            distribution,
+            "Expected a locust class distribution of %s but found %s" % (
+                expected_str,
+                actual_str,
+            ),
+        )
+
+    def test_weight_locusts(self):
+        maxDiff = 2048
+        class BaseLocust(Locust):
+            class task_set(TaskSet): pass
+        class L1(BaseLocust):
+            weight = 101
+        class L2(BaseLocust):
+            weight = 99
+        class L3(BaseLocust):
+            weight = 100
+
+        runner = LocustRunner([L1, L2, L3], mocked_options())
+        self.assert_locust_class_distribution({L1:10, L2:9, L3:10}, runner.weight_locusts(29))
+        self.assert_locust_class_distribution({L1:10, L2:10, L3:10}, runner.weight_locusts(30))
+        self.assert_locust_class_distribution({L1:11, L2:10, L3:10}, runner.weight_locusts(31))
+
+    def test_weight_locusts_fewer_amount_than_locust_classes(self):
+        class BaseLocust(Locust):
+            class task_set(TaskSet): pass
+        class L1(BaseLocust):
+            weight = 101
+        class L2(BaseLocust):
+            weight = 99
+        class L3(BaseLocust):
+            weight = 100
+
+        runner = LocustRunner([L1, L2, L3], mocked_options())
+        self.assertEqual(1, len(runner.weight_locusts(1)))
+        self.assert_locust_class_distribution({L1:1},  runner.weight_locusts(1))
+
+
 class TestMasterRunner(LocustTestCase):
     def setUp(self):
         global_stats.reset_all()