Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Unit Testing: The Myth of Complete Isolation

DZone's Guide to

Unit Testing: The Myth of Complete Isolation

The notion that unit tests should be performed in complete isolation is a myth. We should use isolation to our advantage, wherever it makes our tests more manageable.

· DevOps Zone
Free Resource

Download “The DevOps Journey - From Waterfall to Continuous Delivery” to learn learn about the importance of integrating automated testing into the DevOps workflow, brought to you in partnership with Sauce Labs.

Time and time again, when people talk about unit testing, they mention the fragility of unit tests as some inherent disadvantage of the technique. I disagree. I believe this is just bad unit testing and that the people who hold and spread such opinions are simply victims of what I call The Myth of Complete Isolation.

Fragile Unit Tests

Whether these people believe in it or they are just copy-pasting through their programming career, most of those who experience the pains of fragile unit tests practice excessive isolation of their units under test. They do that by mocking out any single thing their object might be cooperating with.

Let’s consider an imaginary Order class:

public class Order {
    private List<OrderItem> items;

    // c-tor

    public BigDecimal getTotalPrice() {
        return items.stream()
                .map(item -> item.getPrice().multiply(item.getQuantity()))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

class OrderItem {
    private BigDecimal price;
    private BigDecimal quantity;

    // c-tor, getters
}

If we were to test this class in the mock-everything style, it would look somewhat like this:

public class FragileOrderTest {

    @Test
    public void shouldSumItemPrices() throws Exception {
        OrderItem item1 = orderItem(2, 3);
        OrderItem item2 = orderItem(4, 5);
        Order order = new Order(asList(item1, item2));
        assertEquals(BigDecimal.valueOf(26), order.getTotalPrice());
    }

    private OrderItem orderItem(int price, int quantity) {
        OrderItem item = mock(OrderItem.class);
        when(item.getPrice()).thenReturn(BigDecimal.valueOf(price));
        when(item.getQuantity()).thenReturn(BigDecimal.valueOf(quantity));
        return item;
    }
}

Of course, I made the example as small as possible while still making the point, so I don’t waste your valuable time. In reality, these mocking tests usually look much worse than that and contain ridiculous practices like verifying a number of interactions with every single method (sic!).

Now, with code like the one above, if the communication between Order and OrderItem changed for whatever reason, I would have to change every single test related to them. Just consider this simple, obvious refactoring that should be screaming right at your face from the very first code example:

public class Order {
    // same as before

    public BigDecimal getTotalPrice() {
        return items.stream()
                // it's OrderItem's responsibility to do the multiplication!
                .map(OrderItem::getTotalPrice)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

If I did this change and my test looked like the one above, then BOOM!, my test has failed with a NullPointerException in the BigDecimal class. Pretty fragile, isn’t it?

I see at least two arguments why it’s a very, very bad thing. One is that correcting the tests every single time we change a method’s signature is annoying. The other one is that the test suite is supposed to check if I’m not breaking anything, while, instead, it stops working every single change I make!

The Myth of Complete Isolation

I believe that this style of writing mock-everything unit tests is a result of a giant misunderstanding. Unit tests, as the name suggests, are supposed to test individual software units, which can be individual classes, whole aggregates, or whatever else fits. Since we’re only interested in the proper behavior of our unit, we should isolate external dependencies such as databases, remote systems, etc. Hence, we say that unit tests are performed in isolation.

Unfortunately, when most people first brain-parse the phrase “unit tests are performed in isolation,” they understand it as “complete isolation,” i.e. we should isolate the unit from EVERYTHING. Also unfortunately, modern mocking tools are powerful enough to mock anything, including static methods and final classes. Make a combo of these two facts and you have a recipe for fragile unit tests.

This is not to say that isolation itself is bad. On the contrary, I think that the general idea of testing smaller pieces exhaustively to reduce the complexity of testing bigger ones is worthwhile. The actual problem here is coupling between the test code and the production code. The more your tests know about the inner details of your production code, the more these two are coupled together, and so the bigger the chance that changing the latter will break the former.

This brings me to the final point of this section. The notion that unit tests should be performed in complete isolation is a myth. We should use isolation to our advantage, wherever it makes our tests more manageable. At the same time, we should take coupling into account and limit the isolation in places where the pains are bigger than the gains. It’s a trade-off, as almost any programming decision.

Robust Unit Testing

Coming back to our example, we could make our Order class’s test better by giving up a little isolation and using real instances of the OrderItem class. It’s just a simple change in the way order items are created, while the rest of the test stays the same:

public class RobustOrderTest {
    // same as before:
    @Test
    public void shouldSumItemPrices() throws Exception {
        OrderItem item1 = orderItem(2, 3);
        OrderItem item2 = orderItem(4, 5);
        Order order = new Order(asList(item1, item2));
        assertEquals(BigDecimal.valueOf(26), order.getTotalPrice());
    }

    // returns real objects, not mocks! we should not care about the methods calls here!
    private OrderItem orderItem(int price, int quantity) {
        return new OrderItem(BigDecimal.valueOf(price), BigDecimal.valueOf(quantity));
    }
}

Obviously, this example is pretty naive and the code is far from what you should really write in a real project. If you start to use more real objects instead of test doubles, your tests are going to become more robust, but you will experience some new problems e.g. how to instantiate the objects for test purposes. You’re not going to copy and paste methods like orderItem all around the project, are you?

Of course, it ain’t rocket science. There are simple solutions such as static factory methods and patterns like ObjectMother or Test Data Builder to help us deal with this problem and many others that can appear. In the end, it won’t be as easy to write as bashing mocks everywhere, but it will be worthwhile.

Discover how to optimize your DevOps workflows with our cloud-based automated testing infrastructure, brought to you in partnership with Sauce Labs

Topics:
unit testing ,code fragility ,coupling ,mocking ,devops

Published at DZone with permission of Grzegorz Ziemoński, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

THE DZONE NEWSLETTER

Dev Resources & Solutions Straight to Your Inbox

Thanks for subscribing!

Awesome! Check your inbox to verify your email so you can start receiving the latest in tech news and resources.

X

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}