Test code quality

While production code quality is important for keeping a constant development velocity, test code quality is so as well. Tests, however, are mostly not treated in the same way. Experience shows that enterprise projects rarely invest time and effort into refactoring tests.

In general the same practices for high code quality apply for test code as they do for live code. Certain principles are especially important for tests.

First of all, the DRY principle certainly has its importance. On code level this means to avoid repeating definitions, test procedures, and code duplication that contains just minor differences.

For test data, the same principle applies. Experience shows that multiple test case scenarios that use similar test data tempt developers to use copy and pasting. However, doing so will lead to an unmaintainable code base, once changes in the test data have to be made.

The same is true for assertions and mock verifications. Assert statements and verifications that are applied one by one directly in the test method, similarly lead to duplication and challenges with maintenance.

Typically the biggest issue in test code quality is missing abstraction layers. Test cases too often contain different aspects and responsibilities. They mix business with technical concerns.

Let me give an example of a poorly written system test in pseudo code:

@Test
public void testCarCreation() {
    id = "X123A345"
    engine = EngineType.DIESEL
    color = Color.RED

    // verify car X123A345 not existent
    response = carsTarget.request().get()
    assertThat(response.status).is(OK)
    cars = response.readEntity(List<Car>)
    if (cars.stream().anyMatch(c -> c.getId().equals(id)))
        fail("Car with ID '" + id + "' existed before")

    // create car X123A345
    JsonObject json = Json.createObjectBuilder()
            .add("identifier", id)
            .add("engine-type", engine.name())
            .add("color", color.name())

    response = carsTarget.request().post(Entity.json(json))
    assertThat(response.status).is(CREATED)
    assertThat(response.header(LOCATION)).contains(id)

    // verify car X123A345
    response = carsTarget.path(id).request().get()
    assertThat(response.status).is(OK)
    car = response.readEntity(Car)
    assertThat(car.engine).is(engine)
    assertThat(car.color).is(color)

    // verify car X123A345 existent

    // ... similar invocations as before

    if (cars.stream().noneMatch(c -> c.getId().equals(id)))
        fail("Car with ID '" + id + "' not existent");
}

Readers might have noticed that it requires quite some effort to comprehend the test case. The inline comments provide some help, but comments like these are in general rather a sign of poorly constructed code.

The example, however, is similar to the system test example crafted previously.

The challenge with test cases like these is not only that they're harder to comprehend. Mixing multiple concerns, both technically and business motivated into a single class, or even a single method, introduces duplication and rules out maintainability. What if the payload of the car manufacture service changes? What if the logical flow of the test case changes? What if new test cases with similar flow but different data need to be written? Do developers copy and paste all code and modify the few aspects then? Or what if the overall communication changes from HTTP to another protocol?

For test cases, the most important code quality principles are to apply proper abstraction layers together with delegation.

Developers need to ask themselves which concerns this test scenario has. There is the test logical flow, verifying the creation of a car with required steps. There is the communication part, involving HTTP invocations and JSON mapping. There might be an external system involved, maybe represented as a mock server that needs to be controlled. And there are assertions and verifications to be performed on these different aspects.

This is the reason why we crafted the previous system test example with several components, all of them concerning different responsibilities. There should be one component for accessing the application under test, including all communication implementation details required. In the previous example, this was the responsibility of the car manufacturer delegate.

Similar to the assembly line delegate, it makes sense to add one component for every mock system involved. These components encapsulate configuration, control, and verification behavior of the mock servers.

Verifications that are made on test business level are advisably outsourced as well, either into private methods or delegates depending on the situation. The test delegates can then again encapsulate logic into more abstraction layers, if required by the technology or the test case.

All of these delegate classes and methods become single points of responsibility. They are reused within all similar test cases. Potential changes only affect the points of responsibility, leaving other parts of the test cases unaffected.

This requires the definition of clear interfaces between the components that don't leak the implementation details. For this reason it makes sense, especially for the system test scope, to have a dedicated, simple model representation. This model can be implemented simply and straightforward with potentially less type safety than the production code.

A reasonable green field approach, similar to the previous system test example, is to start with writing comments and continuously replacing them with delegates while going down the abstraction layers. This starts with what the test logically executes first, implementation details second. Following that approach naturally avoids mixing business and technical test concerns. It also enables simpler integration of test technology that supports writing tests productively, such as Cucumber-JVM or FitNesse.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.137.184.102