Some time ago I created a demo project to showcase my approach to automated testing, with a particular focus on the arrange/act/assert and gherkin syntax. Let me dive into it and explain some patterns behind it.
Here are the links to the projects implemented in different languages:
The test placement
Various languages, and even frameworks have different conventions when it comes to structuring your test sources. Regardless of that, we’d like them to share a common attribute:
The tests and production sources should be explicitly separated. This helps with a plethora of things:
- It’s easier to IDE to distinguish between production and test sources
- You can easily exclude the test sources from builds & releases
- You can create rules that would prevent production code from depending on test sources (with tools like dependency cruiser or deptrac)
- There is a natural place to put your test doubles
In practice, I proposed the following patterns:
- For PHP, a separate
tests
folder was placed next tosrc
, and anautoload-dev
composer rule was added. The PSR-4 root is the same for both (think:Acme\Project\
), so the test and the test doubles live in the same namespace as the system under test (but different path). This is also convenient, as the IDE will automatically suggest the correct path to place the test when generating it; same when implementing interfaces as test doubles. - TypeScript and JavaScript use
__tests__
subdirectory relative to the system under test
Test doubles
Once you start treating your test sources as first-class citizens, some of the common patterns start to feel deprecated. For example, creating your test doubles using metaprogramming might be quick and convenient, but you wouldn’t do the same for your production sources. This is part of the reasoning behind explicitly implementing test doubles.
This means, that instead of dynamically creating a mocked implementation, a separate class implementing a given interface is created. So you’d have your InMemoryFooRepository
with a trivial implementation (some go as far as testing the test doubles themselves if they become too complicated).
- Having an explicit test double adds semantics to your code. You can immediately recognize the purpose and behaviour of a double
- It helps with readabilty, by hiding away the often verbose setup of a mock from the test case into a separate class
- It promotes reuse, improves static analysis and allows the test doubles to be included in any refactoring
There is nothing wrong with using a mocking library itself. If your dependency does not play a crucial role in your test case, you might as well mock it dynamically for convenience.
Examples of test doubles:
- A Spy in PHP
- A Spy in TypeScript is trully trivial in its implementation
Test cases readability
Some testing frameworks are better than others in this regard, because they allow to use more natural language to describe the test cases. Jest does a fine job, similarly a PHPunit addon called Pest. But even if the test cases are described using sentences, it does not help with the scenario body.
This is why in my tests, each test case is accompanied by a scenario file, which is basically responsible for abstracting away implementation details:
- You have to prepare some existing state (dependencies) and inputs for the test to run. You can do this imperatively in the test source, or move to a scenario and call
givenThisAndThatExists(...)
method. There is no magic, just moving a bunch of stuff to a method. - Then, you usually construct your system under test. This might get unexpectedly complex. This should also be extracted to a scenario method like
whenSomethingHappens()
- Finally, we have our assertions phase, which are similarly implemented using
thenFoo()
orandBar()
methods.
The test scenario keeps the state, and exposes a mini domain specific language to the test case. It’s not meant to be reused between different test cases, so we can focus on making it as specific as we can. It will use other common patterns, described in the next chapter, which will help with reusability.
Extracting the scenario also helps with keeping the arrange/act/assert pattern for your test case. In the end, your test cases look like this, and there isn’t ever a reason for them to become any more complex than that (source):
it("moves within the bounding box", () => {
boundTurtleScenario()
.givenBoundingBox(boundingBox)
.whenTurtleMoves(30)
.thenThePositionIs(new Point(30, 0));
});
it("moves cannot move outside the box", () => {
boundTurtleScenario()
.givenBoundingBox(boundingBox)
.whenTurtleMoves(100)
.thenOutOfBoundsExceptionIsExpected(
"Moved to 100×0 outside of Rectangle<-50×-50, 50×50>",
);
});
Builders and Mothers
You can improve the readability even further by implementing some design patterns aimed to help creating your objects.
- Add a regular builder to create complex objects. It might not be used in production, or there even might be a production builder for a given object with a different interface. That doesn’t matter, you can still build one for your tests.
- An object Mother is basically a factory that produces just any representation of a given object, usually an entity or a value object. If you need to be more specific, your mothers can have many static factory methods.
- Somewhere there’s a line between using a Mother and a Builder (and the Mother itself might be implemented using the Builder, why not?). Do what’s more convenient to you.
Custom assertions
The same principle applies to complicated assertions. Sometimes you’d like to check more than just one scalar matching another one. In these cases, just create a custom assertion classes which take inputs and execute multiple smaller assertions and checks.
Example: BoundingBoxAssertion.ts
Using spies and testing exceptions
In many of the testing frameworks, the three different outputs (and throwing exceptions) is handled in different ways:
- The results are checked using an assertion library such as
expect()
orassertThat()
- Expectations about outgoing messages sent (called methods) need to be set beforehand on the mocked dependencies (
$mock->expect()
) - And handling throwing test cases is done differently altogether, by adding an
@expectedException
annotation/attribute or wrapping the SUT in a lambda and using atoThrow()
expectation
There is no reason for all this. When using a scenario file and test doubles:
- After you execute the method under test, both the result and any exceptions thrown are saved (see:
whenTurtleMoves()
) - All the Spy test doubles store their incoming messages (invoked methods and their arguments) for later inspection
This allows us to simply use the assertion library on different fields, for example:
expect(this.result).toBe(…)
assertInstanceOf($this->exception, RuntimeException::class)
expect(this.spy.calls.save).toHaveLength(1)
They are all abstracted away in a scenario behind a thenSomething()
method.
Closing thoughts and next steps
This is the approach I use 90% of the time, which helps me remove friction from writing unit tests. It sure causes a lot of files/classes to be created, but rarely any of these sources are complex, so they are easy to make. What do you think about this approach? Do you like it? Do you use some of these patterns?
Read more of my articles about testing or consider hiring me for consultation on your project.