Testing actual behavior

Posted on by Matthias Noback

The downsides of starting with the domain model

All the architectural focus on having a clean and infrastructure-free domain model is great. It's awesome to be able to develop your domain model in complete isolation; just a bunch of unit tests helping you design the most beautiful objects. And all the "impure" stuff comes later (like the database, UI interactions, etc.).

However, there's a big downside to starting with the domain model: it leads to inside-out development. The first negative effect of this is that when you start with designing your aggregates (entities and value objects), you will definitely need to revise them when you end up actually using them from the UI. Some aspects may turn out to be not so well-designed at all, and will make no sense from the user's perspective. Some functionality may have been designed well, but only theoretically, since it will never actually be used by any real client, except for the unit test you wrote for it.

Some common reasons for these design mistakes to happen are:

  • Imagination ("I think it works like that.")
  • The need for symmetry ("We have an accept() method so we should also have a reject() method.")
  • Working ahead of schedule ("At some point we'll need this, so let's add it now.")

My recent research about techniques for outside-in development made me believe that there are ways to solve these problems. These approaches to development are known as Acceptance Test-Driven Development (ATDD), or sometimes just TDD, Behavior-Driven Development (BDD), or Specification By Example (SBE). When starting out with the acceptance criteria, providing real and concrete examples of what the application is supposed to do, we should be able to end up with less code in general, and a better model that helps us focus on what matters. I've written about this topic recently.

The downsides of starting with the smallest bricks

Taking a more general perspective: starting with the domain model is a special case of starting with the "smallest bricks". Given the complexity of our work as programmers, it makes sense to begin "coding" a single, simple thing. Something we can think about, specify, test and implement in at most a couple of hours. Repeating this cycle, we could create several of these smaller building blocks, which together would constitute a larger component. This way, we can build up trust in our own work. This problem-solving technique helps us gradually reach the solution.

Besides the disadvantages mentioned earlier (to be summarized as the old motto "You Ain't Gonna Need It"), the object design resulting from this brick-first approach will naturally not be as good as it could be. In particular, encapsulation qualities are likely to suffer from it.

The downsides of your test suite as the major client of your production code

With only unit tests/specs and units (objects) at hand, you'll find that the only client of those objects will be the unit test suite itself. This does allow for experiments on those objects, trying out different APIs without ruining their stability. However, you will end up with a sub-optimal API. These objects may expose some of their internals just for the sake of unit testing.

You may have had this experience when unit-testing an object with a constructor, and you just want to make sure that the object "remembers" all the data you pass into it through its constructor, e.g.

final class PurchaseOrderTest extends TestCase
{
    /**
     * @test
     */
    public function it_can_be_constructed_with_a_supplier_id(): void
    {
        $supplierId = new SupplierId(...);

        $purchaseOrder = new PurchaseOrder($supplierId);

        self::assertEquals(
            $supplierId, 
            $purchaseOrder->supplierId()
        );
    }
}

I mean, you know a purchase order "needs" a supplier ID, but why does it need it? Shouldn't there be some aspect about how you continue to use this object that reveals this fact? Maybe you're making a wrong assumption here, maybe you're even fully aware that you're just guessing.

Try following the rule that a domain expert should be able to understand the names of your unit test methods, and that they would even approve of the way you're specifying the object. It will be an interesting experiment that is worth pushing further than you may currently be comfortable with. The fact that an object can be constructed with "something something" as constructor arguments is barely interesting. What you can do with the object after that will be definitely worth specifying.

Experiment 1: Don't test your constructor

Don't even let your object have any properties, until the moment it turns out you have to. You may have had a similar experience with event sourcing: all the relevant data will be inside the domain event, but you don't necessarily have to copy the data to a property of the entity. The same goes for this experiment: only copy data into an attribute, once you find out that you actually need it, because of some other behavior of the object after constructing it.

This experiment will prevent you from adding unneeded attributes.

So, what happens in a constructor should serve other purposes than just keeping the data into an attribute. But the same goes for the data that can get out. Looking again at the constructor test in the unit test case above, you can see that we needed to add a supplier() getter method to PurchaseOrder, just so we can verify that the data gets copied into one of the object's attributes. The time that passes between constructing a PurchaseOrder and calling supplierId() on it proves that the object can remember its supplier ID. But what's the purpose of that?

Try following the rule that every getter should serve at least one real client, i.e. one caller that is not the unit test itself. The need for "getting something out of an object" can be a very real one of course, but it's a use case that should be consciously implemented by the developer. It should not be there just to make the object "unit-testable".

Experiment 2: Don't add any getters

Don't add any getters just for your unit test. Only add them if there is a real usage for the information you can "get" from the getter, outside of the test suite.

Basically, these two experiments are two sides of the same coin. They can be summarized as: don't keep unnecessary state, don't unnecessarily expose state.

As an example, a PurchaseOrder can have lines, each containing a product ID and a quantity. A first test idea would be this:

final class PurchaseOrderTest
{
    /**
     * @test
     */
    public function you_can_add_a_line_to_it(): void
    {
        $purchaseOrder = new PurchaseOrder();
        $productId = ...;
        $quantity = ...;

        $purchaseOrder->addLine($productId, $quantity);

        self::assertEquals(
            [
                new PurchaseOrderLine($productId, $quantity);
            ],
            $purchaseOrder->lines()
        );
    }
}

Unless there is a real client for the lines() method, besides this unit test, we should not simply start exposing the array of lines this PurchaseOrder uses to remember the lines. In fact, along the way it may turn out that for the actual use cases our application implements, there will be no real client needing a lines() method. Maybe all that's needed is a getLineForProduct() method.

Instead, you should think about the (domain) invariants you need to protect here. Like "you can't add two lines for the same product", or "you can place a purchase order when it has at least one line":

final class PurchaseOrderTest extends TestCase
{
    /**
     * @test
     */
    public function you_cannot_add_two_lines_for_the_same_product(): void
    {
        $purchaseOrder = new PurchaseOrder();
        $productId = ...;
        $quantity = ...;

        $this->expectException(CannotAddLine::class);
        $this->expectExceptionMessage('duplicate product');

        $purchaseOrder->addLine($productId, $quantity);
        $purchaseOrder->addLine($sameProduct = $productId, $quantity);
    }

    /**
     * @test
     */
    public function you_cannot_place_a_purchase_order_when_it_has_no_lines(): void
    {
        $purchaseOrder = new PurchaseOrder();
        // add no lines        

        $this->expectException(CannotPlacePurchaseOrder::class);
        $this->expectExceptionMessage('no lines');

        $purchaseOrder->place();        
    }

    /**
     * @test
     */
    public function you_can_place_a_purchase_order_when_it_has_at_least_one_line(): void
    {
        $purchaseOrder = new PurchaseOrder();
        $purchaseOrder->addLine()

        $purchaseOrder->place();

        /*
         * Depending on how purchase order is actually used, you need to 
         * decide how you want to verify that the order was "placed".
         * Again, don't immediately go and add a getter for that! :)
         */
    }
}

Of course, the implementation of PurchaseOrder now definitely needs a way to remember previously added lines, but we leave its implementation details to PurchaseOrder itself. The tests don't even mention the child entity PurchaseOrderLine, leaving even its implementation details to PurchaseOrder. This is a big win for the flexibility of PurchaseOrders design.

There's one other experiment that I'd like to mention here, which will help you write only the necessary code:

Experiment 3: Don't create any value objects

Start out with primitive-type values only, and let the potential value object wrapping these values prove their own need. For example, accept two floats, latitude and longitude. Accept an email address as a plain-old string value. Only when you're repeating knowledge that's particular for the concept that these values represent, introduce a value object, extracting the repeated knowledge into its own unit.

As an example, latitude and longitude may begin to form a code smell called "Data Clump" - where two values are always to be found together. These values may also be validated at different point (e.g. verifying that they are between -180 and 180 or whatever). However, these value objects are naturally implementation details of the aggregates they belong to. Achieving the maximum level of encapsulation and data hiding for the aggregate, you could (and should) introduce value objects only to protect the overall domain invariants of the aggregate itself.

Conclusion

All of these experiments are not meant to say that you shouldn't have constructors, attributes, getters, or value objects. They are meant to shake your approach to testing, while designing your objects. Tests are not the main use case of an object, they should guide the development process and make sure you write exactly the code that you need. This means not sacrificing the object's encapsulation just to make it testable.

Write the code, and only the code, that supports the use case you're implementing. Write the code, and only the code, that proves that your objects behave the way you are specifying them to behave.

This amounts to adopting an outside-in approach and to make unit tests black box tests. When you do this, you may let go of rules like "every class has to have a unit test". You may also stop creating test doubles for everything, including domain objects. Which itself could lead you to resolve the question "how do I mock final classes?" once and for all: you don't need to do that.

PHP testing aggregate design Domain-Driven Design
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
Marcelo Chiaradía
Hi, interesting post. Something that I find it a bit unclear, you're talking about the drawbacks of inside-in TDD, and the advantages of outside-in, but then it seems that your tests target directly the aggregates (e.g. PurchaseOrder) instead of targeting an application service that implements a full use case and thus testing your aggregate indirectly.Is this assumption correct?If so, does this mean then that you write unit tests for both your aggregates and your application services separately?
Thanks!Marcelo
Matthias Noback

Ah, maybe the example wasn't clear then. The point was to show that testing the entity itself isn't that helpful ;) Indeed, application services, or the application-as-an-interface, is a better place to test your domain logic.

Dmitri Lakachauskis

These are very good guidelines. I would add - don't be a "slave" of your own tools. For instance Symfony's CoverageListener for PHPUnit forces you to write a test for each class [1]. IOW you have to write a separate test for PurchaseOrderLine in order to achieve 100% code coverage (unless you explicitly define @covers annotation for PurchaseOrderTest).

[1] https://symfony.com/doc/cur...

Matthias Noback

Totally agree; I've noticed PhpSpec to have a bit of a negative impact here as well, since you may start feeling like you may *never* create a class without its corresponding spec.