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.

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.

You will find more exciting topics from the adesso world in our latest blog posts.

Picture Björn Thalheim

Author Björn Thalheim

Björn is a software developer and architect with a strong interest in software quality, especially through TDD, clean code and architecture topics.

Our blog posts at a glance

Our tech blog invites you to dive deep into the exciting dimensions of technology. Here we offer you insights not only into our vision and expertise, but also into the latest trends, developments and ideas shaping the tech world.

Our blog is your platform for inspiring stories, informative articles and practical insights. Whether you are a tech lover, an entrepreneur looking for innovative solutions or just curious - we have something for everyone.

To the blog posts

Save this page. Remove this page.