Road to Simplicity: Tests Are Not Tests [Part Two]

August 9, 2020
software engineering test driven development road to simplicity java reactive

Intro

The article cover

This is the second post of Road to Simplicity. And it’s about the role of tests in software writing.

(The first part is about the goal of the series and hexagonal architecture)

Test and correctness

I think that it’s inappropriate to associate test with verification of correctness. After all with a test we can verify that a software module (e.g. a function) returns an expected output with a given input. But we cannot prove correctness in this way. We can prove correctness mathematically. And this is what we do to prove algorithms correctness.

We can see tests as experiments on the software system. And the below quote expresses concisely the above concept:

No amount of experimentation can ever prove me right; a single experiment can prove me wrong

Albert Einstein

So, at most, a test could prove wrongness…

Fear of change

Software aren’t static system. They are in evolution. We can add or delete a feature. We can change its structure. And it changes according the solved problem. There are a lot of reason to change.

However, with an evolution, we can break something that worked in the past. This event is called regression. Eventually we can find regressions manually. But this is a bad road because we could change:

For these reasons we need a tool that guarantees to us non-regression. This tool is test.

Tests free us from the fear of change. Indeed if a test fail we can investigate and fix the issues. Otherwise we can rest assured: we didn’t break something that worked in the past. This implies code malleability.

This is the most evident advantage. But there is another subtler.

Yourself as client of yourself

The goal of Road to Simplicity is to express a methodology to reach simplicity. And there is a connection with tests.

In the last post we already defined a use case. In reality I write use case and its test simultaneously.

These tests are crucial because use cases represents what our software does. And they guarantees simplicity because when we write tests we’re the client of ourselves. And, as programmers, we love simple and clean interface. Furthermore, because test is more code, we don’t want to write unnecessary lines. In this way we are forced to write the most minimal and simplest interface.

Code

In this series I use a demo project to express concretely these ideas. It’s written in Java 11 with reactive programming thanks to ReactiveX. The project is hosted on github.

The software analyzes the capabilities (e.g. java version) of the machine. Then it will expose them through REST API.

In the previous part we expressed our use case (GetCapabilitiesUseCase) as:

public interface GetCapabilitiesUseCase {
  Single<Capabilities> getCapabilities();
}

So here the first test definition. I’m using JUnit 5 with ReactiveX test facility. It verifies that the returned capabilities object is correct. We are passing to the use case factory two mocked port out implementations:

class GetCapabilitiesUseCaseTest {
  @Test
  void ok() throws Throwable {
    String javaVersion = randomString();
    Long networkSpeed = randomLong();
    GetCapabilitiesUseCase useCase = UseCaseFactory.getCapabilitiesUseCase(
      () -> Single.just(javaVersion),
      () -> Single.just(networkSpeed)
    );

    TestObserver<Capabilities> useCaseObserver = useCase
      .getCapabilities()
      .test();
    assertTrue(useCaseObserver.await(100, TimeUnit.MILLISECONDS));

    useCaseObserver
      .assertResult(new Capabilities(javaVersion, networkSpeed));
  }

  private String randomString() {
    return UUID.randomUUID().toString();
  }

  private Long randomLong() {
    return ThreadLocalRandom.current().nextLong();
  }
}

During the writing I discovered that there is a room of improvement. Currently our use case could return negative network speed.

This is possible because the Capabilities class allows these values. So I improved its constructor with an exception:

@Value
@Builder
public class Capabilities {
  public Capabilities(String javaVersion, Long networkSpeed) {
    this.javaVersion = javaVersion;
    this.networkSpeed = networkSpeed;

    if (this.networkSpeed < 0L) throw new IllegalArgumentException("Network speed should be greater than 0!");
  }

  private final String javaVersion;
  private final Long networkSpeed;
}

Then I added the test class about Capabilities:

class CapabilitiesTest {
  @Test
  void with_negative_network_speed() throws Throwable {
    String javaVersion = randomString();
    Long networkSpeed = randomNegativeLong();

    try {
      new Capabilities(javaVersion, networkSpeed);
      throw new IllegalStateException("Invalid capabilities built!");
    } catch (IllegalArgumentException e) {
    }
  }

  private String randomString() {
    return UUID.randomUUID().toString();
  }

  private Long randomNegativeLong() {
    return ThreadLocalRandom.current().nextLong(1L, Long.MAX_VALUE) * -1L;
  }
}

And I updated the use case test:

class GetCapabilitiesUseCaseTest {
  @Test
  void ok() throws Throwable {
    String javaVersion = randomString();
    Long networkSpeed = randomNonNegativeLong();
    GetCapabilitiesUseCase useCase = UseCaseFactory.getCapabilitiesUseCase(
      () -> Single.just(javaVersion),
      () -> Single.just(networkSpeed)
    );

    TestObserver<Capabilities> useCaseObserver = useCase
      .getCapabilities()
      .test();
    assertTrue(useCaseObserver.await(100, TimeUnit.MILLISECONDS));

    useCaseObserver
      .assertResult(new Capabilities(javaVersion, networkSpeed));
  }

  private String randomString() {
    return UUID.randomUUID().toString();
  }

  private Long randomNonNegativeLong() {
    return ThreadLocalRandom.current().nextLong(0L, Integer.MAX_VALUE);
  }
}

I run the tests and they are successful.

At this point we can extend our domain without fear of regressions. We have tests that protect us. Furthermore we have a neat use case interface.

Conclusion

For this post that’s all. I like to stress that tests are not for the present. We are not verifying with them software correctness. Tests are for the future because they guarantee malleability, extensibility and maintainability. At the end they guarantee code simplicity.

Stay tuned! :D

Resources

Playwright on Steroids: Overcoming Limits With Object-Oriented Programming

November 14, 2023
playwright oop object thinking multithreading performance java

Object Thinking, Boundaries and Reality

January 29, 2022
oop object thinking java

Implementing an Event Loop in Java for Fun and Profit

November 12, 2021
oop event loop object thinking java