Shades of Tests

Practical examples of software testing in Java using Spring Boot Test without mocking frameworks.


Disclaimer: For purposes of this text, when I’m talking about testing I mean functional testing. Albeit non-functional testing is an important part of software development I will ignore it here entirely.

There are many shades of testing in software development. It’s handy for every developer to know the differences and trade-offs to make the testing as effective as possible.

I use the word “effective” on purpose as I see a lot of effort invested in making testing as efficient as possible, but unfortunately in the wrong kind of testing.

Features-rich tools are nice to write and execute end-to-end tests efficiently, but results will never be as quick, cheap, applicable and stable as simple but numerous unit tests.

It’s like trying to optimize a bubble-sort algorithm: you can make it quite efficient, but it still will never beat the merge-sort.

Understanding different kinds of tests and where to use what is crucial for effectivity.

The Test Pyramid

When talking about testing enterprise software, the (in)famous Test Pyramid always comes up.

Developers find the Test Pyramid often annoying for two main reasons:

The former could be really justified, especially for GUI software or projects that focus particularly on integration. That’s why I have to emphasize I will talk about enterprise software development exclusively for the rest of this text.

The latter reason for the dislike is the problem I’d like to address.

Simple Test Pyramid
Simple Test Pyramid

The common understanding of the Test Pyramid is its volume aspect: the bigger the volume the bigger the amount of tests of that kind. Simply put, the test base should contain a lot of unit tests, some integration tests, and just a few UI tests.

However, the Pyramid has more to offer. With the increasing volume increase speed and costs as well. While unit tests are quick and cheap, UI tests tend to be very slow and expensive.

Test Pyramid with speed and cost axis
Test Pyramid with speed and cost axis

The reason is in the complexity of the tests. Integration tests are obviously more complicated than unit tests and so on. More components are involved, more communication must be worked out, the computer(s) must execute more instruction. Tests are more difficult (expensive) to develop, set up, execute.

These additional aspects explain the economic reasons behind the different recommended amounts. And what’s more, there is another characteristic we can put to the axis: applicability.

With increasing volume of the Pyramid the applicability of tests declines. It’s obvious that unit tests of the core domain will always apply, but there could be several UIs accessing its functionality. Components can be integrated into multiple different systems as well.

With the absence of unit tests, integration tests would need to be duplicated for every system and UI tests for every client. This is expensive, slow, and tedious. Besides, the core domain is usually the most stable piece of software while its clients tend to be pretty volatile. This leads us to another aspect: stability.

Tests that frequently require changes and adjustments are often abandoned or ignored. Stability of tests goes down with decreasing volume of the Pyramid.

Last but not least, tests on the top of the Pyramid are often flaky. Flaky tests are especially annoying when the test suite is slow.

Test Pyramid characteristics
Test Pyramid characteristics

Kinds of Tests

The terminology is vague and ambiguous and there are no official definitions of different types of tests. The terms are overloaded and can obtain various meanings among different people.

I’m not aspiring to standardize the terminology, you are free to use any name you like. However, to discuss the concept it is important to unify the name, at least for purposes of this text.

We will refine the Test Pyramid into the following levels:

Extended Test Pyramid
Extended Test Pyramid

(Atom) Unit Tests

This definitely the most unfortunate name of all. The problem is, a unit doesn’t necessarily be a small piece of software with a single responsibility and a well-defined API for a business capability, as it should in the context of unit testing. A unit means generally any piece of software, in an extreme case the entire system.

Albeit it’d be really handy to have a more descriptive name for the unit test, unfortunately I’m not aware of any. We can try it with things like element tests or atom tests to emphasize the atomicity of the unit under test, but the term unit tests is strongly adopted and ubiquitous and is here to stay with us forever.

Unit Test
Atom has no dependencies

How does a unit test look like anyway? We don’t really need any special magic here:

class PromoProductTest {

  @Test
  void promo_product_is_not_deliverable() {
    Product product = new PromoProduct(
        "Test product", 123);

    assertThat(product.deliverable()).isFalse();
  }
}

No dependencies, tested business capabilities against the API, this is a nice unit test.

Here is the beauty about unit testing: unit tests require code to be testable.

Testable code must be simple, cohesive and loosely coupled. Easy to say, hard to do. That’s usually the reason a codebase has so few unit tests - they are hard to write because the code is not testable.

Tests are the very first client of the code and help you to understand the API to be easy to use and understand.

Another benefit of good tests is their documentary function. Tests show the proper usage of code to serve as an exhaustive user reference.

I often refer to testing as to the “king’s discipline” of software development. Only good developers can do it right.

True (atom) unit tests require the domain model to be pure. This is not always easy or even possible to achieve.

Component Tests

When purity is not feasible, test doubles come to help. External dependencies such as databases or filesystems can be stubbed for testing via alternative implementations of resource adapters.

It’s important to focus on the business functionality. With test-doubles you can easily find yourself writing tests that check only stubbed calls or integration with doubles. In this case, it’s time to move on to component testing.

Component tests lie somewhere between unit and integration tests. They are no pure unit tests as more than one “atom” is involved.

However, component tests are no pure integration tests either, as the unit is not completely and correctly integrated - stubbing is often used, only directly needed components are integrated, not the whole system.

Component Test
Component’s dependencies are stubbed

Spring Boot offers so-called "slicing" where only needed pieces of the system are included in the test:

@DataJpaTest
@Import(JpaProductConfig.class)
class PromoteTest {

  @Autowired
  private Promote promote;

  @Test
  void product_is_promoted() {
    var product = promote.product(new Promote.ToPromote(
        "Test product", 123));

    assertAll(() -> assertThat(product.id()).isNotNull(),
              () -> assertThat(product.deliverable()).isFalse());
  }
}

Integration Tests

Integration tests check the component fully integrated in the system. The system is seen as a black box accessible only via its public APIs.

The system is usually bootstrapped from within the test, which makes it possible to run the system with special configuration. For example, a test database with a testing dataset could be used.

Also, the component under the test is typically deployed into the core system without any other components.

Integration Test
Component is integrated in a system slice

Spring Boot brings the @SpringBootTest annotation to support integration testing:

@SpringBootTest(classes = Application.class,
    webEnvironment = RANDOM_PORT)
class ProductsTestIT {

  @LocalServerPort
  private int port;

  @Test
  void product_is_promoted() {
    with()
        .port(port)
        .basePath("/products/promoted")
        .contentType(ContentType.JSON)
        .body("{\"name\":\"Test\","
             + "\"price\":123}")
        .post().
    then()
        .statusCode(201)
        .body("id", is(notNullValue()))
        .body("name", equalTo("Test"))
        .body("price", equalTo(123));
  }
}

End-to-End Tests

End-to-end tests, also known as system tests, runs typically in three phases:

As the system is started only once, there is no room for adjustments based on special tests’ needs. The system runs as a separate process, deployed in an environment as similar to production as possible.

E2E Test
Component is integrated in the whole system

Maven Failsafe Plugin address this kind of testing, as well as Testcontainers:

@Testcontainers
class ProductsTestE2E {

  @Container
  public GenericContainer app = new GenericContainer(
      new ImageFromDockerfile()
          .withFileFromPath("app.jar",
              Path.of("build/libs/app.jar"))
          .withDockerfileFromBuilder(b -> b
              .from("openjdk:11-jre-slim")
              .copy("app.jar", "/app.jar")
              .entryPoint("java", "-jar", "/app.jar")
              .build()))
      .withExposedPorts(8080);

  private int port;

  @BeforeEach
  public void setUp() {
    port = app.getFirstMappedPort();
  }

  @Test
  void product_is_promoted() {
    with()
        .port(port)
        .basePath("/products/promoted")
        .contentType(ContentType.JSON)
        .body("{\"name\":\"Test\","
             + "\"price\":123}")
        .post().
    then()
        .statusCode(201)
        .body("id", is(notNullValue()))
        .body("name", equalTo("Test"))
        .body("price", equalTo(123));
  }
}

UI Tests

In the context of this text, UI testing is different from testing a UI.

UIs themselves should be unit-tested in isolation against a stubbed backend, but this is no full replacement for UI testing a UI integrated in the system.

UI tests in enterprise software development are end-to-end tests with actions triggered from a GUI (web or desktop) instead of Restful or similar APIs.

UI Test
Component is accessed via UI

UI tests tend to be very slow, unstable and flaky. Unfortunately, it is still not unusual to have a test base containing UI tests mostly or even only.

The test base should contain UI tests for elementary user interactions with the UI. Especially to check edge conditions of user input, validation and error handling towards the user.

The amount of UI tests should be kept on a small maintainable amount, everything must be automated.

There are plenty of tools for automation of UI testing such as Selenium:

class ProductsTestUI {

  private WebDriver webDriver;

  @BeforeEach
  void setUp() {
    webDriver = new HtmlUnitDriver();
    webDriver.get("http://localhost:8080/ui");
  }

  @Test
  void product_is_promoted() {
    var form = webDriver.findElement(By.id("form-promote"));
    form.findElement(By.className("name")).sendKeys("Test");
    form.findElement(By.className("price")).sendKeys("123");
    form.findElement(By.className("submit")).click();

    var list = webDriver.findElement(By.id("products-promo"))
        .findElements(By.className("product"));

    assertAll(
        () -> assertThat(list).hasSize(1),

        () -> assertThat(list.get(0).findElement(
            By.className("id")).getText()).isNotNull(),

        () -> assertThat(list.get(0).findElement(
            By.className("name")).getText()).isEqualTo("Test"),

        () -> assertThat(list.get(0).findElement(
            By.className("price")).getText()).isEqualTo("123")
    );
  }
}

UX Tests

Albeit there is no excuse to have an automatic test for everything, one kind of testing does require manual labor.

Look’n’feel and user experience (UX) in general cannot be evaluated by any machine, a human is required here.

UX Test
User experience matters

Source Code

The source code for discussed examples can be found on my GitHub.

Happy testing!