By “is” I mean “should be”.
By “External” I mean – decoupled from your business logic by an adapter.
Concurrency is always implemented by a third-party API. That makes it eligible for externalization, i.e. refactoring it away from your business logic.
There are two types of relationship your business logic should have with concurrency:
- Business logic is calling code that is going to spawn threads, wait and return collected results in the calling thread.
- The business logic object is passed into the concurrency lib, which calls it in spawned thread.
Either way, your business logic doesn’t have anything to do with threads. Which means that the tests of the business logic component don’t have anything to do with threads.
The only test that will test concurrency is the integration test of the concurrency adapter. Yes, integration test – the test that is not run regularly in the TDD cycle, but only during building on a CI server.
The reason for that is that those tests are slow. They are testing adapters, and adapters use network sockets, the file system and other slow-moving parts. In the case of concurrency, its slow because of artificially created delay in task execution so we can accurately measure the total amount of time for the execution of multiple sleeper tasks – the only true measure if something is concurrent (i.e. happening at the same time).
assert(cpuCount() > 1) task = () -> sleepMs(100) start = currentTimeMs() concurrencyAdapter.execute(task, task, task, task, task) end = currentTimeMs() assert(end - start < 500)
- Sleeper task does nothing but sleep X time.
- Execute Y sleepers and measure total duration.
- Total duration should be less than X * Y, because X * Y is the total execution time if sleepers run sequentially (and all other stuff around them takes 0 time to execute). So, if total duration is less than X * Y, tasks must have executed concurrently.
Notice the cute little assertion at the beginning of the test. If the machine has 1 CPU, the test will fail. You can’t execute threads concurrently with 1 CPU. In that case, its better that the test fails with the message “cpuCount not > 1” than with “duration not < 500”. Said differently, when the test fails because of execution taking too long, we can be sure its a bug in the code, not a shortage of CPUs.
If your business logic has the type 1 relationship with concurrency (see above), that means that the concurrency adapter is injected to it. That in turn means that it’s unit test must inject it.
However, you cannot inject a mock, since they don’t do anything except record calls to their methods. You need to inject a fake implementation of the concurrency adapter which executes everything in the same thread, without using any concurrency lib.
Keep that implementation simple! If you can’t, write a test for it.