Back to Home

Review of Growing Object-Oriented Software, Guided by Tests

tdd · unit testing · mocks

Review of Growing Object-Oriented Software, Guided by Tests

    This article is a review of Growing Object-Oriented Software, Guided by Tests (GOOS for short). In it I will show how you can implement a project example from a book without using mocks.

    The purpose of the article is to show how the use of mok can harm the code and how much easier the same code becomes if you get rid of mok. A secondary goal is to single out the tips from the book that personally seem reasonable to me and those that, on the contrary, do more harm than good. There are quite a lot of both in the book.

    English version: link .

    Good parts


    Let's start with the good stuff. Most of them are in the first two sections of the book.

    The authors define the goal of automated testing as creating a “safety net” that helps detect regressions in the code. In my opinion, this is really the most important advantage that tests give us. Safety net helps to achieve confidence that the code works as expected, which, in turn, allows you to quickly add new functionality and refactor existing ones. A team becomes much more productive if it is confident that changes to the code do not lead to breakdowns.

    The book also describes the importance of setting up an deployment environment in the early stages. This should be the first priority of any new project, as allows you to identify potential integration errors in the early stages, before writing a significant amount of code.

    For this, the authors propose to start with the construction of the walking skeleton, the simplest version of the application, which at the same time affects all layers of the application in its implementation. For example, if it is a web application, the skeleton can show a simple HTML page that requests a string from a real database. This skeleton should be covered with an end-to-end test, from which the creation of the test suite will begin.

    This technique also allows you to focus on deploying a deployment pipeline without focusing on application architecture.

    The book offers a two-level TDD cycle:

    image

    In other words, start each new functionality with an end-to-end test and work your way to successfully passing this test through the regular red-green-refactor cycle.

    End-to-end is more like a measure of progress. Some of these tests may be in the “red” state, as The feature has not yet been implemented, this is normal. Unit tests at the same time act as a safety net and should be green all the time.

    It is important that end-to-end tests affect as many external systems as possible, this will help to identify integration errors. At the same time, the authors acknowledge that some external systems will have to be replaced with stubs anyway. The question of what to include in the end-to-end tests should be decided for each project separately, there is no universal answer.

    The book suggests expanding the classic 3-step TDD cycle by adding a fourth step to it: to make the error message more understandable.

    image

    This will help make sure that if the test crashes, you can understand that it’s not so easy to look at the error message without launching the debugger.

    Authors recommend developing the application “in a vertical way” (end to end) from the very beginning. Do not spend too much time polishing the architecture, start with some request coming from outside (for example, from the UI) and process this request completely, including all layers of the application (UI, logic, database) with the minimum possible amount of code. In other words, do not build the architecture in advance.

    Another great tip is to test behavior, not methods. Very often this is not the same thing, because a unit of behavior can affect several methods or even classes.

    Another interesting point is the recommendation to make the system under test (SUT) context-independent:

    "No object should have an idea about the system in which it is running."

    This is essentially the concept of domain model isolation. Domain classes should not depend on external systems. Ideally, you should be able to completely tear them out of your current environment and run them without any extra effort. In addition to the obvious benefit of better code testability, this method simplifies your code as well. You are able to focus on the subject area without paying attention to aspects that are not related to your domain (database, network, etc.).

    The book is the source of the rather well-known rule “Only mock types that you own”. In other words, use moki only for types that you wrote yourself. Otherwise, you cannot guarantee that your moki correctly model the behavior of these types.

    Interestingly, during the book, the authors themselves violate this rule a couple of times and use moki for types from external libraries. Those types are pretty simple, so there really isn’t much point in creating your own wrappers over them.

    Bad parts


    Despite the many valuable tips, the book also gives potentially harmful recommendations, and there are quite a few such recommendations.

    The authors are supporters of the mockist approach to unit testing (more about the differences here: mockist vs classicist ) even when it comes to communication between individual objects within a domain model. In my opinion, this is the biggest drawback of the book, all the rest are a consequence of it.

    To substantiate their approach, the authors cite the definition of OOP given by Alan Kay:

    “The main idea is messaging. The key to creating a good and extensible application is to design how its various modules communicate with each other, and not how they are arranged internally. ”

    They then conclude that interactions between objects are what you should focus on first and foremost during unit testing. According to this logic, communication between classes is what ultimately makes the system what it is.

    There are two problems in this view. First, the definition of OOP given by Alan Kay is inappropriate here. It is rather vague to draw such far-reaching conclusions on its basis and has little in common with modern OOP languages.

    Here is another famous quote from him:

    "I came up with the phrase" object-oriented, "and I did not mean C ++."

    And of course, you can safely replace C ++ here with C # or Java.

    The second problem with this approach is that individual classes are too fine-grained to be considered as independent communicators. The way they communicate with each other often changes and has little to do with the end result, which we must ultimately verify in tests. The communication pattern between objects is an implementation detail and becomes part of the API only when communication crosses the boundaries of the system: when your domain model begins to communicate with external services. Unfortunately, the book does not make these differences.

    The disadvantages of the approach proposed by the book become apparent if you look at the project code from Chapter 3. The focus on communication between objects not only leads to fragile tests due to their involvement in implementation details, but also leads to an over-sophisticated design with cyclic dependencies, header interfaces and an excessive number of layers of abstractions.

    In the rest of this article, I’m going to show how a project from a book can be modified and what effect this has on unit tests.

    The original code base is written in Java, the modified version is written in C #. I rewrote the project completely, including unit tests, end-to-end tests, UI and an emulator for the XMPP server.

    Project


    Before plunging into the code, let's look at the subject area. The project from the book is Auction Sniper. A bot that participates in auctions on behalf of a user. Here is its interface:

    image

    Item Id - the identifier of the item that is currently being sold. Stop Price - the maximum price that you as a user are willing to pay for it. Last Price - The last price you or other bidders bid for this item. Last Bid is the last price you made. State - state of the auction. In the screenshot above, you can see that the application won both items, which is why both prices are the same in both cases: they came from your application.

    Each line in the list represents a separate agent that listens for messages coming from the server and responds to them by sending commands in response. Business rules can be summarized in the following image:

    image

    Each agent (also called Auction Sniper) starts at the top of the picture, in the Joining state. He then waits until the server sends an event with the current state of the auction - the last price, the username of the bidder and the minimum price increase necessary to break the last bid. This type of event is called Price.

    If the required bid is less than the stop price that the user set for the item, the application sends its bid (bid) and enters the bidding state. If the new Price event shows that our bid is leading, Sniper does nothing and goes into Winning state. Finally, the second event dispatched by the server is the Close event. When it arrives, the application looks at what status it is currently in for this item. If in Winning, then it goes to Won, all other statuses go to Lost.

    That is, in fact, we have a bot that sends commands to the server and supports the internal state machine.

    Let's look at the architecture of the application proposed by the book. Here is her diagram (click to enlarge):

    image

    If you think that it is overcomplicated beyond measure for such a simple task, this is because it is. So what problems do we see here?

    The very first observation that catches your eye is a large number of header interfaces. This term refers to an interface that completely copies a single class implementing this interface. For example, XMPPAuction is one-to-one associated with the Auction interface, AcutionSniper - with the AuctionEventListener, and so on. Interfaces with a single implementation are not an abstraction and are considered a design smell .

    Below is the same diagram without interfaces. I removed them to make the structure of the diagram more understandable.

    image

    The second problem here is cyclical dependencies. The most obvious of these is between XMPPAuction and AuctionSniper, but it is not the only one. For example, AuctionSniper refers to SnipersTableModel, which in turn refers to SniperLauncher and so on until the connection comes back to AuctionSniper.

    Cyclic dependencies in the code load our brain when we try to read and understand this code. The reason is that with such dependencies you do not know where to start. To understand the purpose of one of the classes, you need to put in your head a whole graph of classes cyclically connected with each other.

    Even after I completely rewrote the project code, I had to turn to diagrams quite often to understand how the various classes and interfaces relate to each other. We humans understand the hierarchies well, with cyclic graphs we often have difficulties. Scott Wlaschin wrote an excellent article on this subject: Cyclic dependencies are evil .

    The third problem is the lack of isolation of the domain model. Here's what the architecture looks like in terms of DDD:

    image

    The classes in the middle make up the domain model. At the same time, they communicate with the auction server (left) and with the UI (right). For example, SniperLauncher communicates with XMPPAuctionHouse, AuctionSniper communicates with XMPPAcution and SnipersTableModel.

    Of course, they do this using interfaces, not real classes, but, again, adding header interfaces to the model does not mean that you automatically start following Dependency Inversion principles.

    Ideally, the domain model should be self-sufficient, the classes inside it should not talk to classes from the outside world, nor using specific implementations, nor their interfaces. Proper isolation means that the domain model can be tested using a functional approach without involving mobs.

    All these shortcomings are a common consequence of a situation where developers focus on testing interactions between classes within a domain model, rather than their public API. Such an approach leads to the creation of header interfaces, because otherwise, it becomes impossible to “kill” neighboring classes, to a large number of cyclic dependencies and domain classes directly communicating with the outside world.

    Let's take a look at the unit tests themselves. Here is an example of one of them:

    @Test public void reportsLostIfAuctionClosesWhenBidding() {
      allowingSniperBidding();
      ignoringAuction();
      context.checking(new Expectations() {{
        atLeast(1).of(sniperListener).sniperStateChanged(
          new SniperSnapshot(ITEM_ID, 123, 168, LOST));
        when(sniperState.is(“bidding”));
      }});
      sniper.currentPrice(123, 45, PriceSource.FromOtherBidder);
      sniper.auctionClosed();
    }
    

    Firstly, this test focuses on communication between classes, which leads to the need to create and maintain a significant amount of code related to creating mocks, but this is not the most important thing here. The main drawback here is that this test contains information about the details of the implementation of the test object. The when statement here means that the test knows about the internal state of the system and simulates this state in order to test it.

    Here is another example:

    private final Mockery context = new Mockery();
    private final SniperLauncher launcher =
      new SniperLauncher(auctionHouse, sniperCollector);
    private final States auctionState =
      context.states(“auction state”).startsAs(“not joined”);
    @Test public void
    addsNewSniperToCollectorAndThenJoinsAuction() {
      final Item item = new Item(“item 123”, 456);
      context.checking(new Expectations() {{
        allowing(auctionHouse).auctionFor(item); will(returnValue(auction));
        oneOf(auction).addAuctionEventListener(with(sniperForItem(item)));
        when(auctionState.is(“not joined”));
        oneOf(sniperCollector).addSniper(with(sniperForItem(item)));
        when(auctionState.is(“not joined”));
        one(auction).join(); then(auctionState.is(“joined”));
      }});
      launcher.joinAuction(item);
    }
    

    This code is a clear example of a leak of knowledge about the details of implementing a system. The test in this example implements a full-fledged state machine to verify that the tested class calls its neighbors' methods in this particular order (last three lines):

    public class SniperLauncher implements UserRequestListener {
      public void joinAuction(Item item) {
        Auction auction = auctionHouse.auctionFor(item);
        AuctionSniper sniper = new AuctionSniper(item, auction);
        auction.addAuctionEventListener(sniper); // These
        collector.addSniper(sniper); // three
        auction.join(); // lines
      }
    }
    

    Due to the high connectivity with the internals of the system under test, tests like this are very fragile. Any non-trivial refactoring will lead to their fall regardless of whether this refactoring broke something or not. This in turn significantly reduces their value, because tests often give false positives and because of this they cease to be perceived as part of a reliable safety net.

    The full source code of the project from the book can be found here: link .

    Alternative implementation without the use of mok


    All of the above are quite serious statements, and, obviously, I need to back them up with an alternative solution. The full source code of this alternative solution can be found here .

    In order to understand how a project can be implemented with proper isolation of the domain domain, without cyclic dependencies and without an excessive amount of unnecessary abstractions, let's look at the application functions. It receives events from the server and responds to them with some commands, supporting the internal state machine:

    image

    And that’s essentially all. In reality, this is an almost ideal functional programming architecture, and nothing prevents us from implementing it as such.

    Here's how the alternative solution chart looks:

    image

    Let's look at a few important differences. First, the domain model is completely isolated from the outside world. The classes in it do not speak directly with the view model or with the XMPP Server, all links are directed to domain classes, and not vice versa.

    All communication with the outside world, whether it is a server or a UI, is given to the Application Services layer, the role of which in our case is played by AuctionSniperViewModel. It acts as a shield that protects the domain model from the unwanted influence of the outside world: it filters incoming events and interprets outgoing commands.

    Secondly, the domain model does not contain cyclic dependencies. The structure of the classes is tree-like, which means that the potential new developer has a clear place from which he can start reading this code. He can start by molding the tree and move up the tree step by step, without having to place the entire class diagram in his head at a time. The code from this particular project is pretty simple, of course, so I'm sure you would not have a problem reading it even if there were circular dependencies. However, in more complex scenarios, a clear tree structure is a big plus in terms of simplicity and readability.

    By the way, the well-known DDD pattern - Aggregate - is aimed at solving this particular problem. By grouping several entities into a single aggregate, we reduce the number of links in the domain model and thus make the code simpler.

    The third important point here is that the alternate version does not contain interfaces. This is one of the benefits of having a fully isolated domain model: you just don't need to inject interfaces into your code if they don't represent a real abstraction. In this example, we do not have such abstractions.

    Classes in the new implementation are clearly divided according to their purpose. They either contain business knowledge — these are classes within the domain model — or they communicate with the outside world — classes outside the domain model — but never both. This separation of duties allows us to focus on one problem at a time: we either think about domain logic, or decide how to respond to incentives from the UI and the auction server.

    Again, this simplifies the code, which means it makes it more supported. Here's what the most important part of the Application Services layer looks like :

    _chat.MessageReceived += ChatMessageRecieved;
    private void ChatMessageRecieved(string message)
    {
        AuctionEvent ev = AuctionEvent.From(message);
        AuctionCommand command = _auctionSniper.Process(ev);
        if (command != AuctionCommand.None())
        {
            _chat.SendMessage(command.ToString());
        }
    }
    

    Here we get the string from the auction server, transform it into event (validation is included in this step), pass it to the sniper, and if the resulting command is not None, send it back to the server. As you can see, the lack of business logic makes the Application Services layer trivial.

    Mock-free tests


    Another advantage of an isolated domain model is the ability to test it using a functional approach. We can consider each part of the behavior in isolation from each other and check the final result that it generates, without paying attention to how exactly this result was achieved.

    For example, the following test checks how Sniper, who has just joined an auction, reacts to receiving a Close event:

    [Fact]
    public void Joining_sniper_loses_when_auction_closes()
    {
        var sniper = new AuctionSniper(“”, 200);
        AuctionCommand command = sniper.Process(AuctionEvent.Close());
        command.ShouldEqual(AuctionCommand.None());
        sniper.StateShouldBe(SniperState.Lost, 0, 0);
    }
    

    It checks that the resulting command is empty, which means sniper is not taking any action, and that the state becomes Lost after that.

    Here is another example:

    [Fact]
    public void Sniper_bids_when_price_event_with_a_different_bidder_arrives()
    {
        var sniper = new AuctionSniper(“”, 200);
        AuctionCommand command = sniper.Process(AuctionEvent.Price(1, 2, “some bidder”));
        command.ShouldEqual(AuctionCommand.Bid(3));
        sniper.StateShouldBe(SniperState.Bidding, 1, 3);
    }
    

    This test verifies that a sniper sends an application when the current price and minimum increment are less than the set price limit.

    The only place where mokas can potentially be justified is when testing the Application Services layer, which communicates with external systems. But this part is covered by end-to-end tests, so in this particular case this is not necessary. By the way, the end-to-end tests in the book are great, I did not find anything that could be changed or improved in them.

    The source code for an alternative implementation can be found here .

    Conclusion


    A focus on communication between the individual classes leads to fragile tests, as well as damage to the project architecture itself.

    To avoid these disadvantages:

    • Do not create header interfaces for domain classes.
    • Minimize the number of circular dependencies in your code.
    • Isolate the domain model: do not let domain classes communicate with the outside world.
    • Reduce unnecessary abstractions.
    • Focus on checking the state and the final result when testing a domain model, not communication between classes.

    Pluralsight Course


    I just got a new Pluralsight course on pragmatic unit testing. In it, I tried to talk about the practice of building unit tests, leading to the best result with the least effort. Guidelines from the article above became part of this course and are considered in it in detail, with many examples.

    I also have several dozen trial codes that give unlimited access to Pluralsight for a period of 30 days (to the entire library, not just my course). If someone needs - write in a personal, I will share it with pleasure.

    Course reference: Building a Pragmatic Unit Test Suite .

    Read Next