Kugelschreiber und Notebook auf einem Schreibtisch

adesso Blog

Developers know unit tests fairly well, even more integrative approaches like @SpringBootTest. But many lack a clear design/development/test strategy and stick to their favorite programming language. Acceptance Test Driven Design (ATDD) is a structured approach to design your tests and program outside in, keeping the focus on the larger function blocks instead of individual classes (e.g. test behavior, not classes). This approach may benefit from abstracting the acceptance tests to a non-programming language like cucumber, allowing even non-programmers to write test scenarios which will eventually be executed automatically. This article demonstrates this with a small testcase and a fully functional, little Spring Boot/Java project.

Introduction: Getting Things Done

As a use case, I took David Allens Getting Things Done method (Wikipedia):

Getting Things Done (GTD) is a personal productivity system developed by David Allen and published in a book of the same name. GTD is described as a time management system. Allen states “there is an inverse relationship between things on your mind and those things getting done”.

The GTD method rests on the idea of moving all items of interest, relevant information, issues, tasks and projects out of one’s mind by recording them externally and then breaking them into actionable work items with known time limits. This allows one’s attention to focus on taking action on each task listed in an external record, instead of recalling them intuitively.

First feature: Collect Thoughts

The first, very charming idea of GTD is to get everything that pops into your mind out of it into a safe place where it can be retrieved later for further processing. The idea is to think as little as possible about whatever came into your mind. It might be anything, from an item you need to put onto your grocery store list to an electric car business idea which might make you the second-richest person in the world.

The verbal description of the use-case is: A thought can be collected into your inbox. A thought is just a few words, a little text. At any time, it can be retrieved from the inbox.

Defining acceptance test scenarios

Before even starting on our feature, let’s define the acceptance tests of our feature:

Feature: Capture Stage
  Scenario: Collect Thought
    When Thought "Send Birthday Wishes to Mike" is collected
    Then Inbox contains "Send Birthday Wishes to Mike"

src/test/resources/features/collect-thought.feature

Here, we let something magical happen! Instead of defining acceptance tests scenarios in the QA phase after implementing the feature, creating the acceptance happened before giving the implementation task to the developer. This is called “shift left” as the QA activity of defining acceptance scenarios is moved from the right (end) to the left (more in the beginning) of the process. This approach is very charming as it forces the requirements engineer to formulate a more concrete description of the feature and not just some rough idea presented in one or two sentences (no offence – this is the type of “laziness” that also developers display oftentimes).

Setting Things Up

We now do something fairly standard: we start a Java/Maven project and let IntelliJ generate the initial pom.xml for us. In the process, we will add a few dependencies for an in-memory DB for testing and cucumber into the pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>de.adesso.thalheim.gtd</groupId>
    <artifactId>cucumber_demo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
</project>

Because I want to start a Spring Boot project and I’m a fan of Lombok, I add the following dependencies and the Spring Boot Starter parent relation to the pom.xml:

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.4</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>
    ...
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
            <scope>provided</scope>
        </dependency>
    </dependencies>

After doing that, we want to achieve the following two targets:

  • We want the application to start up with an external database (on my local machine, I let a PostgreSQL DB run in Docker)
  • We want a simple ´@SpringBootTest` to start up with an embedded H2 DB.

Long story short, several things need to be made for this. The pom.xml needs a few more dependencies:

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>test</scope>
        </dependency>

We need to configure a datasource which will be used in normal operations of our application in the src/main/resources/application.yml:

spring.jpa:
  database: POSTGRESQL
  hibernate.ddl-auto: create-drop
  show-sql: true
spring.datasource:
  driverClassName: org.postgresql.Driver
  url: jdbc:postgresql://localhost:5432/mydb
  username: foo
  password: bar

In case you wondered: The PostgreSQL DB can be easily started with docker run --name postgres-db -e POSTGRES_PASSWORD=docker -p 5432:5432 -d postgres and the DB and user simply created with CREATE DATABASE ... and CREATE USER ....

We need to configure an alternative datasource which will be used when unit testing our application in the src/test/resources/application.yml:

spring.datasource:
  driver-class-name: org.h2.Driver
  url: jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
  username: sa
  password: sa

Remarks on clarity from the start

This is a little off topic, but particularly important. Many projects fail to set up their codebase as early as possible for this type of test (integrative component test with an embedded database). I suggest, you set this up as early as possible, before starting to write a single line of productive code in your project. It provides clean test possibilities for all developers during the development of the project.

Add and configure the Cucumber Maven dependency

In order to run the test specification, we need a few dependencies in the pom.xml:

<dependency>
   <groupId>io.cucumber</groupId>
   <artifactId>cucumber-java</artifactId>
   <version>6.11.0</version>
</dependency>
<dependency>
   <groupId>io.cucumber</groupId>
   <artifactId>cucumber-spring</artifactId>
   <version>6.11.0</version>
</dependency>
<dependency>
   <groupId>io.cucumber</groupId>
   <artifactId>cucumber-junit</artifactId>
   <version>6.11.0</version>
</dependency>

Now, we can add the acceptance test we have already defined above into our codebase in src/test/resources/features/collect-thought.feature:

Feature: Capture Stage
  Scenario: Collect Thought
    When Thought "Send Birthday Wishes to Mike" is collected
    Then Inbox contains "Send Birthday Wishes to Mike"
Making the cucumber test specification run

To make Maven run this specification, we need some boilerplate code.

First, a test class which points to the cucumber test specifications:

@RunWith(Cucumber.class)
@CucumberOptions(features = {"src/test/resources/features"})
public class CucumberTest {
}

src/test/java/de/adesso/thalheim/gtd/CucumberTest.java

Also, a Cucumber Context needs to be provided, we use the @SpringBootTest for that:

@CucumberContextConfiguration
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class CucumberSpringBootDemoApplicationTest {

src/test/java/de/adesso/thalheim/gtd/CucumberSpringBootDemoApplicationTest.java

You will need the RANDOM port to not interfere with your regular running local instance of this service.

Now, if we let Maven run, during the test run an error will pop up that the glue code is missing. So, let’s add that:

public class CaptureStepDefinitions {
    @When("Thought {string} is collected")
    public void thoughtIsCollected(String thought) {
        Assert.fail("Implement me!");
    }
    @Then("Inbox contains {string}")
    public void inboxContains(String thought) {
        Assert.fail("Implement me!");
    }
}

src/test/java/de/adesso/thalheim/gtd/CaptureStepDefinitions.java

Now, our test specification fails. But it does not fail for the correct reason. So, let’s implement the glue code in src/test/java/de/adesso/thalheim/gtd/CaptureStepDefinitions.java:

    @Value(value = "")
    private int port;
    @When("Thought {string} is collected")
    public void thoughtIsCollected(String thought) throws IOException {
        // given
        HttpPost post = new HttpPost("http://localhost:%d/gtd/inbox".formatted(port));
        post.setEntity(new StringEntity(thought));
        // when
        HttpResponse postResponse = HttpClientBuilder.create().build().execute(post);
        // then
        Assertions.assertThat(postResponse.getStatusLine().getStatusCode()).isEqualTo(200);
    }

Now, the test defines that we need a POST endpoint which is exposed in the context path gtd/thoughts. It should return an http status code 200.

While I was at it I added the AssertJ Core Library to the Maven dependencies. assertThat(...)... sounds more like BDD than standard JUnit assert statements.

If you now run the Cucumber tests or Maven build, the test execution will fail, because no REST controller offers a proper endpoint. Now we have a test which fails for the right reason:

[ERROR] Collect Thought  Time elapsed: 0.248 s  <<< ERROR!
org.apache.http.conn.HttpHostConnectException: Connect to localhost:8080 [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1] failed: Connection refused: connect
Caused by: java.net.ConnectException: Connection refused: connect

The reason our test fails is because there is no REST endpoint listining where we expect it.

This means we can finally write production code:

@RestController
@RequestMapping("gtd/inbox")
@Slf4j
public class InboxController {
    @PostMapping
    public void collect(@RequestBody String thought) {
        // TODO: implement me!
        log.debug("Received " + thought);
    }
}

src/main/java/de/adesso/thalheim/gtd/controller/InboxController.java

Now, the acceptance test fails again as there is no glue code for the when-clause in the cucumber scenario. Let’s write this glue code in src/test/java/de/adesso/thalheim/gtd/CaptureStepDefinitions.java:

    @Value(value = "")
    private int port;
    @Then("Inbox contains {string}")
    public void inboxContains(String thought) throws IOException {
        // given
        HttpUriRequest get = new HttpGet("http://localhost:%d/gtd/inbox".formatted(port));
        // when
        CloseableHttpResponse response = HttpClientBuilder.create().build().execute(get);
        // then
        String entity = EntityUtils.toString(response.getEntity());
        assertThat(StringUtils.strip(entity)).isEqualTo("[{\"description\":\"%s\"}]".formatted(thought));
    }

Note on the level of abstraction

Here you can see, I kept the glue code and therefore the acceptance test on an abstraction level above the concrete interface. Of course, one could have just @Inject the REST controller and use plain Java for testing, which would have made things easier. But it would have made the test more concrete than necessary, thereby binding the test to implementation details.

Now, we can write a method for the GET endpoint. It should return a list of classes containing exactly one field named “description”. We need to implement the controller, so let’s write this in normal TDD style with a test case first:

@ExtendWith(MockitoExtension.class)
class InboxControllerTest {
    @InjectMocks
    InboxController controller;
    @Mock
    ThoughtRepository repository;
    @Captor
    ArgumentCaptor<Thought> thoughtArgumentCaptor;
    @Test
    public void testPutThoughtIntoRepository() throws UnsupportedEncodingException {
        // given
        String thoughtDescription = "foiaxöniso";
        // when
        controller.collect(thoughtDescription);
        // then
        verify(repository).save(thoughtArgumentCaptor.capture());
        assertThat(thoughtArgumentCaptor.getValue().getDescription()).isEqualTo(thoughtDescription);
    }
    @Test
    public void testGetAllThoughts() {
        // given
        String thoughtDescription = "foiaxöniso";
        Thought thought = new Thought(UUID.randomUUID(), thoughtDescription);
        when(repository.findAll()).thenReturn(Set.of(thought));
        // when
        List<Thought> thoughts = controller.get();
        // then
        assertThat(thoughts).hasSize(1);
        assertThat(thoughts.iterator().next()).isEqualTo(thought);
    }
}

src/test/java/de/adesso/thalheim/gtd/controller/InboxControllerTest.java

Now we can finish writing the Controller, Entity, Repository etc.

@RestController
@RequestMapping("gtd/inbox")
@Slf4j
public class InboxController {
    @Inject
    private ThoughtRepository thoughtRepository;
    @PostMapping
    public void collect(@RequestBody String thought) {
        log.debug("Received " + thought);
        Thought theThought = new Thought(UUID.randomUUID(), thought);
        thoughtRepository.save(theThought);
    }
    @GetMapping
    public List<Thought> get() {
        Iterable<Thought> all = thoughtRepository.findAll();
        return StreamSupport.stream(all.spliterator(), false).toList();
    }
}

src/main/java/de/adesso/thalheim/gtd/controller/InboxController.java

@RequiredArgsConstructor
@AllArgsConstructor
@Entity
public class Thought {
    @Id
    private UUID id;
    @Getter
    private String description;
}

src/main/java/de/adesso/thalheim/gtd/controller/Thought.java

public interface ThoughtRepository extends CrudRepository<Thought, UUID> {}

src/main/java/de/adesso/thalheim/gtd/repository/ThoughtRepository.java

Of course, you would never expose an @Entity as the result type of a REST call. But for demonstration purposes, we’re fine here.

That’s it. We have driven a small feature implementation by writing an acceptance test scenario and glue code to test the behavior of a part of our application first in Cucumber.

Wrapping it up

I have said I would do ATTD here. This means I first created a failing acceptance test, and then implemented only interfaces. And when I got further, I used normal unit tests to finish the internals of my implementation. The acceptance tests form an outer, the unit tests an inner loop of the implementation process.

Writing Cucumber scenarios first has the big advantage of forcing your requirements engineer to make requirements as clear as possible.

Before writing a single line of productive code, I took the time and made sure that in this dummy project the execution of unit tests, @SpringBootTest, and Cucumber tests were possible.

I have kept the acceptance tests free of implementation details which are not relevant to them, hence raising refactoring safety. I would try to do the same with regular @SpringBootTests.

If you like, you can see all code in this repository.

Bild Björn Thalheim

Autor Björn Thalheim

Björn ist Softwareentwickler und -architekt mit einem starken Interesse an Softwarequalität, insbesondere durch TDD, Clean Code und Architekturthemen.

Diese Seite speichern. Diese Seite entfernen.