Unit test vs integration test: where the line actually is
A unit test catches a defect inside one class; an integration test catches a defect in how classes and infrastructure connect. Test at the level where the bug would be introduced, with a Spring example of each.
Originally written on 6 March 2017. Migrated from a WordPress blog and reformatted.
The standard definitions are simple. A unit test exercises one class in isolation. An integration test exercises several components together, often including infrastructure. The difficulty is not the definition but the decision: for a given piece of code, which one is worth writing.
One rule
Test at the level where the defect would be introduced.
Logic that lives entirely inside one class, such as a discount calculation, a parser, or a state machine, is broken by a change to that class. A unit test catches it, runs in milliseconds, and points directly at the fault.
Behaviour that depends on how components fit together, such as a repository query, a transaction boundary, or a controller's mapping of a request to a service call, is broken by a change to the wiring. A unit test with mocked collaborators cannot catch it, because the mocks encode the wiring the test author assumed. An integration test can.
unit test integration test
what it runs one class the real object graph
dependencies mocked or stubbed real, or in-memory equivalents
catches logic errors wiring, query, config errors
speed milliseconds hundreds of ms to seconds
failure points at the exact method somewhere in the path
A unit test that earns its place
public class DiscountCalculatorTest {
private final DiscountCalculator calc = new DiscountCalculator();
@Test
public void appliesTieredDiscountAtThreshold() {
assertThat(calc.discountFor(new BigDecimal("100.00")))
.isEqualByComparingTo("5.00");
}
@Test
public void noDiscountBelowThreshold() {
assertThat(calc.discountFor(new BigDecimal("99.99")))
.isEqualByComparingTo("0.00");
}
}
No framework, no mocks, no database. The class has one responsibility and the tests enumerate its edges.
An integration test that earns its place
@RunWith(SpringRunner.class)
@DataJpaTest
public class OrderRepositoryTest {
@Autowired OrderRepository orders;
@Autowired TestEntityManager em;
@Test
public void findsOpenOrdersForCustomerOrderedByDate() {
Customer c = em.persist(new Customer("acme"));
em.persist(new Order(c, Status.OPEN, date("2017-03-01")));
em.persist(new Order(c, Status.CLOSED, date("2017-03-02")));
em.persist(new Order(c, Status.OPEN, date("2017-03-03")));
List<Order> result = orders.findOpenByCustomer(c.getId());
assertThat(result).extracting(o -> o.getCreated())
.containsExactly(date("2017-03-03"), date("2017-03-01"));
}
}
The query runs against a real (in-memory) database. A mistake in the JPQL, the sort direction, or the entity mapping fails this test. A unit test of the same repository would mock the EntityManager and could not fail for any of those reasons.
The common mistakes
Unit-testing the wiring. A service test that mocks the repository and then verifies repository.save() was called is checking that the code was written the way the test expects. It does not check that saving works.
Integration-testing the logic. Spinning up the Spring context to test that a discount is 5 percent is slow and, when it fails, tells you less than the unit test would. Put the logic in a class that can be tested alone.
Treating the pyramid as a quota. The usual advice is many unit tests and fewer integration tests. That is a description of a typical codebase, not a target. A codebase that is mostly wiring around a database should have mostly integration tests.
Summary
Ask where the bug would come from. If from inside one class, write a unit test. If from the way classes and infrastructure connect, write an integration test. Each test should be at the lowest level that can actually fail for the defect it guards against.