|
From BDD in Action, Second Edition by John Ferguson Smart This article explores BDD automation tools that are available for use. |
Take 40% off BDD in Action, Second Edition by entering fccsmart2 into the discount code box at checkout at manning.com.
Many specialized BDD tools can be used to automate your acceptance criteria. Popular choices include tools like Cucumber (for Java, JavaScript, Ruby and many other languages), SpecFlow (for .NET), and Behave (for Python). Although they aren’t indispensable, these tools make it easier to express the automated tests in a structured form similar to the “Given … When … Then” expressions commonly used. This makes it easier for product owners and testers to understand and identify the automated acceptance criteria, which in turn can help increase their confidence in the automated tests and in the automated acceptance-testing approach in general.
Setting up a project with Maven and Cucumber
Throughout this article, I’ll illustrate examples using several different BDD tools. We write executable specifications using Cucumber and Java[1] and the project is built and run using Maven.[2] The test reports are generated using Serenity BDD,[3] an open source library that makes it easier to organize and report on BDD test results.
The source code for this article’s available on GitHub[4] and on the Manning website. We’ll walk through the full process, following along with Tess and Dave as they implement the automated acceptance tests in Cucumber, and use these tests to drive their development process. If you want to follow along, you’ll need a development environment with the following software installed:
- A Java JDK (the sample code was developed using OpenJava 12.0.2, but it should work fine with JDK 1.8 or higher)
- Maven 3.6.x
- Git (if you want to see the sample solution in action
The first thing Tess and Dave do is create a new Maven project using a Maven Archetype. Maven Archetypes are a convenient way to create skeleton projects with the correct directory layout and Maven build script. To create a new project using Cucumber and Serenity, they run the following command on the command line:
$ mvn archetype:generate -Dfilter=serenity-cucumber4
This command lists the available matching archetypes, and prompts them to choose the archetype the want to use. Only one archetype is offered, and they enter “1”. Next, they enter a Maven group id (“manning.bddinaction”) an artefact id (“train-timetables”), and an initial version (they leave this as the default of “1.0-SNAPSHOT”). Maven also prompts for the root package of the project (which they leave as the default value, which is the same as the group id). The whole process looks something like this:
[INFO] Generating project in Interactive mode [WARNING] No archetype found in remote catalog. Defaulting to internal catalog [INFO] No archetype defined. Using maven-archetype-quickstart (org.apache.maven.archetypes:maven-archetype-quickstart:1.0) Choose archetype: 1: local -> net.serenity-bdd:serenity-cucumber4-archetype (Serenity automated acceptance testing project using Selenium 2, JUnit and Cucumber-JVM) Choose a number or apply filter (format: [groupId:]artifactId, case sensitive contains): : 1 Define value for property 'groupId': manning.bddinaction Define value for property 'artifactId': train-timetables Define value for property 'version' 1.0-SNAPSHOT: : Define value for property 'package' manning.bddinaction: : Confirm properties configuration: groupId: manning.bddinaction artifactId: train-timetables version: 1.0-SNAPSHOT package: manning.bddinaction Y: : Y [INFO] --------------------------------------------------------------------- [INFO] Using following parameters for creating project from Archetype: serenity-cucumber4-archetype:2.0.72 [INFO] --------------------------------------------------------------------- - [INFO] BUILD SUCCESS [INFO] --------------------------------------------------------------------- [INFO] Total time: 30.818 s [INFO] Finished at: 2019-09-22T17:14:18-04:00 [INFO] ---------------------------------------------------------------------
This creates a new project structure in the train-timetables directory, like this one:
|____train-timetables | |____pom.xml #1 | |____build.gradle #2 | |____src | | |____main | | | |____java #3 | | | | |____manning | | | | | |____bddinaction | | | | | | |____app | | | | | | | |____Calculator.java | | |____test #4 | | | |____resources | | | | |____features #5 | | | | | |____math | | | | | | |____adding_numbers.feature | | | |____java | | | | |____manning | | | | | |____bddinaction | | | | | | |____acceptancetests | | | | | | | |____AcceptanceTestSuite.java #6 | | | | | | |____steps | | | | | | | |____MathsStepDefinitions.java
#1 The Maven pom.xml build script
#2 An equivalent Gradle build script
#3 Application code goes here
#4 Test code goes here
#5 Cucumber feature files go under src/test/resource
#6 This is the main test runner class
Go into the train-timetables folder and run the “mvn verify” command. This should download any dependencies you need and run the simple feature file that comes bundled with the project skeleton:
$ cd train-timetables $ mvn verify [INFO] Scanning for projects... … [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] Running manning.bddinaction.AcceptanceTestSuite … [INFO] --------------------------------------------------------------------- [INFO] BUILD SUCCESS [INFO] --------------------------------------------------------------------- [INFO] Total time: 6.343 s [INFO] Finished at: 2019-09-22T18:17:16-04:00 [INFO] ---------------------------------------------------------------------
Now that they’ve a project skeleton up and running, Tess and Dave move on to more interesting work: implementing the scenarios they discovered earlier on in a form that Cucumber can execute.
Recording the executable specifications in Cucumber
In Cucumber, we record scenarios like the ones we wrote earlier in special files called Feature Files. These files have a “.feature” suffix, and are designed to contain, as the name suggests, all of the scenarios that describe the behavior of a particular feature.
Here’s how Tess records the scenario that she defined with Jill in the previous question:
Listing 1. An acceptance criteria expressed in Cucumber
Feature: Show next departing trains As a commuter travelling between two stations on the same line I want to know what time the next trains for my destination will leave So that I can spend less time waiting at the station Scenario: Next train going to the requested destination on the same line Given the T1 train to Chatswood leaves Hornsby at 8:02, 8:15, 8:21 When Travis want to travel from Hornsby to Chatswood at 8:00 Then he should be told about the trains at: 8:02, 8:15
This is little more than a structured version of the example we discussed earlier. The words in bold (Feature, Scenario, Given, When and Then) are keywords which mark the structure of the feature file. Everything else is plain business language.
A common convention for Java projects is to place the feature files under the src/test/resources/features directory. Within this directory, feature files can be grouped by high-level capabilities or themes. For example, as this project progresses, the team might end up with directories such as:
- itineraries (itinerary calculations and timetable information)
- commuters (personalized trip data for commuters)
- notifications (delay notifications for commuters)
For now, one feature file is enough. Tess creates the “itineraries” directory in the features folder and adds a file called show_next_departing_trains.feature. She also deletes the math directory, which comes with the skeleton project for demonstration purposes. The directory structure now looks like this:
|____src | |____test | | |____resources | | | |____features | | | | |____itineraries | | | | | |____show_next_departing_trains.feature
This now counts as an executable specification. Although there’s no code behind the scenario to make it test anything, you can still execute it. If you want to try this out, go into the train-timetables directory and run the following command:
$ mvn clean verify
This generates a set of reports in the target/site/serenity directory.[5] If you open the index.html file in this directory and click on the only test in the Test table at the bottom of the screen, you should see something like figure 1.
Figure 1. The Cucumber feature in the acceptance test reports
At this point, the scenario is no longer a simple text document; it’s now an executable specification. It can be run as part of the automated build process to automatically determine whether a particular feature has been completed. When tests like this are first executed, they’re flagged as “pending,” which means, in BDD terms, that the test has been automated but the code that implements the supporting features hasn’t yet been written. As the features are implemented and the acceptance tests succeed, they’re marked as “passed” to indicate that you’ve completed work in this area.
The language used in these scenarios are close to the terms that Jill used in the conversations with the team. When the scenarios appear in the test reports, the use of this familiar language makes it easier for testers, end users, and other non-developers to understand what features are being tested and how they’re being tested.
Living documentation is more than test reporting. It should also report on the state of all of your specified requirements, even the ones that don’t have any tests yet. This gives a more complete picture of your project and your product. For example, Serenity BDD reports both on features which have been built and tested, but also features that have only been planned and defined in the form of unimplemented feature files (you can see an example in figure 2).
Figure 2. Living documentation should also tell you what requirements you have specified, even if no tests exist for them yet.
Automating the executable specifications
Now it’s time to turn this executable specification into an automated test. First Tess double-checks the test runner class that came with the skeleton project. It’s configured to run all of the feature files under the features directory, and looks like this:
@RunWith(CucumberWithSerenity.class) @CucumberOptions(features="src/test/resources/features/", glue="manning.bddinaction" ) public class AcceptanceTestSuite {}
At a future date, she may add some more options to this runner class, but for now it’s fine.
Next, she and Dave write test automation codes which are called whenever their scenario is executed. Remember, their scenario looks like this:
Scenario: Next train going to the requested destination on the same line Given the T1 train to Central leaves Hornsby at 08:02, 08:15, 08:21 When Travis want to travel from Hornsby to Chatswood at 08:00 Then he should be told about the trains at: 08:02, 08:15
They need to write a method for each one of these Given, When and Then steps. Cucumber uses special annotations (named rather appropriately @Given, @When and @Then) to know which method to run for each scenario step. These annotations use regular expressions to identify the bits of the Cucumber scenario that represent test data (such as the train line and stations, and the times). For example, the annotation for the first Given step needs to pass in the train line, the departure and destination stations, and the departure time. We often call this code glue code, because it binds the text in the scenario steps to actual test automation or application code.
The initial code that Tess and Dave write for this step looks something like this:
@Given("the (.*) train to (.*) leaves (.*) at (.*)") public void theTrainLeavesAt(String line, String destination, String departure, String departingAt){}
Tess places these methods in a class called DepartingTrainsStepDefinitions, which she places in a steps package right underneath the test runner class. The complete class looks like the one in Listing 2.
Listing 2. A basic Cucumber scenario implementation
package manning.bddinaction.steps; import cucumber.api.java.en.Given; import cucumber.api.java.en.Then; import cucumber.api.java.en.When; import java.time.LocalTime; import java.util.List; import java.util.stream.Collectors; import static java.util.Arrays.stream; public class DepartingTrainsStepDefinitions { @Given("the (.*) train to Central leaves Hornsby at (.*)"). #1 public void theTrainLeavesAt(String line, String departingAt) {} @When("Travis want to travel from (.*) to (.*) at (.*)"). #2 public void travelBetween(String departureStation, String destinationStation, String departingAt) {} @Then("he should be told about the trains at: (.*)"). #3 public void shouldBeToldAboutTheTrainsAt(String departureTimes) {} }
#1 A Given step
#2 A When step
#3 A Then step
For teams practicing BDD, code like this is the gateway to production code. It tells you precisely what your underlying code needs to do to satisfy the business requirements. From here we can start to think not only about what our production code should do, but also how best to test it.
And this is what Tess and Dave do next. Let’s follow their progress as they turn these empty methods into fully fledged automated acceptance tests, and then use these tests to drive out the production code.
Implementing the glue code
BDD practitioners like to start with the outcome they need to obtain and work backwards. Tess and Dave start with the @Then
step, which expresses the outcome they expect. They imagine a service to implement the timetable logic. They aren’t sure what this service should look like, but they know that they need a list of proposed departure times. Writing the glue code gives them the perfect opportunity to experiment with different API designs, and see what they like best.
Tess updates the @Then method to look like this:
@Then("he should be told about the trains at: (.*)") public void shouldBeToldAboutTheTrainsAt(String expectedDepartures) { List<LocalTime> expected = localTimesFrom(expectedDepartures); #1 assertThat(proposedDepartures).isEqualTo(expected); #2 } private List<LocalTime> localTimesFrom(String listOfDepartureTimes) { return stream(listOfDepartureTimes.split(",")) .map(LocalTime::parse) .collect(Collectors.toList()); }
#1 Convert the comma-separated list of times to a list of Java LocalTime objects
#2 Use an AssertJ assertion to check that the dates that the service returned are the same as the ones listed in the Cucumber scenario.
Naturally, this code doesn’t compile yet. They need to declare the proposedDepartures
variable and figure out where the proposed train will come from; this will come in good time.
Writing the executable specifications before writing the code is a great way to discover and flesh out the technical design you need in order to deliver the business goals. It helps you discover what domain classes make sense, what services you need, and how the services need to interact with each other. It also helps you think about how to make your code easy to test, and code which is easy to test, is easy to maintain.
In this case, Tess imagines a simple method called findNextDepartures() to find the next departure times from a given station. When she adds this code to the step definition method, the result looks something like this:
List<LocalTime> proposedDepartures; @When("Travis want to travel from (.*) to (.*) at (.*)") #1 public void travelBetween(String departure, #2 String destination, #2 String departingAt) {, #2 LocalTime departureTime = LocalTime.parse(departingAt); proposedDepartures = itineraryService.findNextDepartures(departure, #3 destination, #3 departingAt); #3 }
#1 The When step
#2 Passing in parameters from the When step
#3 Finding the next departure times
“Where does the itineraryService come from?” Dave wonders. “We’ll need to define it earlier on.” He adds the following line at the top of the class to create the service:
ItineraryService itineraryService = new ItineraryService();
Next, he creates the ItineraryService class itself, along with an empty implementation of the findNextDepartures() method.
“The itineraryService needs to know about the timetable details we mention in the Given step,” points out Tess. “That’s a separate concern, and it probably should go in its own class”.
“I agree”, says Dave, “but let’s not get sidetracked by that; the timetable logic could be hairy. Let’s create a TimeTable interface to model how our itinerary service needs to interact with the timetable service.”
This is a typical BDD approach, often referred to as “outside-in”. As we implement a layer, we discover other things it needs to function, other services it needs to call. We have a choice; we can either build these things straight away, or we can put them to one side, model them as an interface or a dummy class, and come back to them later. If the first approach works fine for simpler problems, for more complex code it’s generally much more efficient to stay focused on the work at hand.
To get this acceptance criteria to pass, our heroes now need to implement the findNextDepartures()
method, but to make this scenario green. They need to change gears and go from acceptance testing to unit testing. As you’ll see, acceptance testing is used to demonstrate the high-level, end-to-end behavior of an application, and unit testing is used to build up the components that implement this behavior
Acceptance tests often use a full or near-full application stack, whereas unit tests concentrate on individual components in isolation. Unit tests make it easier to focus on getting a particular class working and identifying what other services or components it needs. Unit tests also make it easier to detect and isolate errors or regressions. You’ll typically write many small unit tests in order to get an acceptance criterion to pass (see figure 2.8). At the unit testing level, teams practicing BDD often use Test Driven Development, or TDD, to drive out the implementation.
TDD is deceptively simple. You write a test that describes how you expect your application to behave. Naturally, it fails, because you haven’t written any code yet. You write enough code to make this test pass. And once your test passes, you take a look at your code and think about how you could tidy it up, refactor it to improve the design or make it easier to read and understand when you come back to it down the track.
Both Behavior Driven Development and Test Driven Development are examples of Example Driven Development. In both approaches, we use concrete examples to illustrate, discuss and understand the behavior we want to implement. The main difference is that TDD tends to be a developer-centric activity, and it operates at the detailed level of classes, methods and APIs. BDD, as we have seen, is team-centric, and looks at the bigger picture of business goals, features and scenarios.
Figure 3. You’ll typically need to write many low-level, TDD-style unit tests to get an automated acceptance criterion to pass.
Let’s get back to Tess and Dave. They’ll write their unit tests using JUnit 5, a common unit testing library for Java. Modern unit testing libraries nowadays often include good support for BDD-style testing.
They start off with a unit test that illustrates a basic use case of the findNextDepartures()
method. You can see the code in Listing 3.
Listing 3. A simple BDD-style unit test
package manning.bddinaction.itineraries; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.time.LocalTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @DisplayName("When finding the next train departure times") class WhenFindingNextDepatureTimes { @Test @DisplayName("we should get the first train after the requested time") void tripWithOneScheduledTime() { // Given ItineraryService itineraryService = new ItineraryService(); #1 // When List<LocalTime> proposedDepartures = itineraryService.findNextDepartures(LocalTime.of(8,25), #2 "Hornsby", #2 "Central"); #2 // Then assertThat(proposedDepartures) #3 .containsExactly(LocalTime.of(8,30)); #3 } }
#1 Create a new itinerary service
#2 Look up the departure times from Hornsby to Central after 8:25
#3 Check that the service returns the expected time of 8:30
“I’m not happy with this test”, says Tess. “I can see that we want to find the next train from Hornsby to Central after 8:25, but it doesn’t make it clear why the answer is 8:30 – are we relying on test data that might change? Where does this time come from?”
“Good point”, says Dave. “We need to include a timetable and set up some test data. We don’t know exactly what the TimeTable API should look like yet, but we could wrap this in a method that creates a timetable which always returns a certain list of departure times.”
Dave refactors the unit test to look like this:
private LocalTime at(String time) { return LocalTime.parse(time, DateTimeFormatter.ofPattern("H:mm")); #1 } private TimeTable departures(LocalTime... departures) { return null; }#2 @Test @DisplayName("should the first after the departure time") void tripWithOneScheduledTime() { // Given timeTable = departures(at("8:10"), at("8:20"), at("8:30")); #3 itineraries = new ItineraryService(timeTable); #4 // When List<LocalTime> proposedDepartures = itineraries.findNextDepartures(at("8:25"),"Hornsby","Central"); #5 // Then assertThat(proposedDepartures).containsExactly(at("8:30")); #6 }
#1 A utility method to create a LocalDate
#2 This method returns a properly configured TimeTable, once we know how the TimeTable class works.
#3 Create a timetable with trains that depart at specified times
#4 Create an itinerary service that uses this timetable
#5 Look up the departure times from Hornsby to Central after 8:25
#6 Check that the service returns the expected time of 8:30
“We could take a guess at what methods the TimeTable class needs, but it might be easier to start to implement the findNextDepartures() method and see what information we need the timetable to provide,” suggests Tess.
After some experimenting, Tess and Dave agree that the main job of this method is to find out which lines go between the two stations (this is something the timetable should know about), and to find the next two trains to arrive after the specified time. You can see the full implementation in Listing 4.
Listing 4. The ItineraryService class
package manning.bddinaction.itineraries; import manning.bddinaction.timetables.TimeTable; import java.time.LocalTime; import java.util.List; import java.util.stream.Collectors; public class ItineraryService { private TimeTable timeTable; public ItineraryService(TimeTable timeTable) { this.timeTable = timeTable; } public List<LocalTime> findNextDepartures(LocalTime departureTime, String from, String to) { List<String> lines = timeTable.findLinesThrough(from, to); #1 return lines.stream() .flatMap(line -> timeTable.getDepartures(line) #2 .stream()) #2 .filter(trainTime -> !trainTime.isBefore(departureTime)) #3 .sorted() #4 .limit(2) #5 .collect(Collectors.toList()); #6 } }
#1 Ask the timetable for the lines going through the departure and destination. stations
#2 Ask the timetable for the list of departure times on these lines
#3 Keep only the departure times which aren’t before the requested departure time
#4 Show the earlier trains first
#5 Only keep the first two departure times
#6 Return these as a list of LocalTime objects
Finally, they implement a dummy version of the timetable in their test, one that returns a hard-coded list of times. The logic of the TimeTable class becomes much more complex than this, but from the point of view of the Itinerary service, all it needs to know is that the timetable sends back the right departure times when it asks for them.
This is a good example of the outside-in development style commonly practiced in BDD teams. In writing this class, Tess and Dave have discovered two things they need from the TimeTable class: it needs to tell them which train lines go through any two stations, and also, what time do trains leave a given station on each line. They have precisely identified the methods they need and what these methods should do.
Based on this implementation, the TimeTable
interface needs to include at least these methods:
public interface TimeTable {
List<String> findLinesThrough(String from, String to);
List<LocalTime> getDepartures(String lineName, String from);
}
It might have more later on, but from the point of view of the itinerary service, these are enough.
Now that they’ve defined the TimeTable
interface, they can return to the original test and complete the departures()
method, to return the departure times we ask it to:
private TimeTable departures(LocalTime... departures) { return new TimeTable() { @Override public List<String> findLinesThrough(String from, String to) { return List.of("T1"); } @Override public List<LocalTime> getDepartures(String line, String from) { return List.of(departures); } }; }
Success! With this dummy implementation, their first unit test passes.
“Great!”, says Tess. “What else does this class need to do?”
The pair continue to explore the behavior of the itinerary service and add a few more tests to illustrate different facets of this behavior. For example, they want to add scenarios where multiple scheduled times are returned, to make sure that only the first two are returned. They also want to check the edge case where there are no more trains. You can read the full test class in Listing 5.
Listing 5. The completed WhenFindingNextDepatureTimes class
package manning.bddinaction.itineraries; import manning.bddinaction.timetables.TimeTable; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @DisplayName("When finding the next departure times") class WhenFindingNextDepatureTimes { private LocalTime at(String time) { return LocalTime.parse(time, DateTimeFormatter.ofPattern("H:mm")); } private static TimeTable departures(LocalTime... departures) { #1 return new TimeTable() { @Override public List<String> findLinesThrough(String departingFrom, String goingTo) { return List.of("T1"); } @Override public List<LocalTime> getDepartures(String line, String from) { return List.of(departures); } }; } TimeTable timeTable; ItineraryService itineraries; @Test @DisplayName("should the first after the departure time") void tripWithOneScheduledTime() { timeTable = departures(at("8:10"), at("8:20"), at("8:30")); #2 itineraries = new ItineraryService(timeTable); List<LocalTime> proposedDepartures = itineraries.findNextDepartures(at("8:25"), #3 "Hornsby","Central"); #3 assertThat(proposedDepartures).containsExactly(at("8:30")); #4 } @Test @DisplayName("should propose the next 2 trains") void tripWithSeveralScheduledTimes() { #5 timeTable = departures(at("8:10"), at("8:20"), at("8:30"), at("8:45")); itineraries = new ItineraryService(timeTable); List<LocalTime> proposedDepartures = itineraries.findNextDepartures(at("8:05"),"Hornsby","Central"); assertThat(proposedDepartures) .containsExactly(at("8:10"), at("8:20")); } @Test @DisplayName("No trains should be returned if none are available"). #6 void anAfterHoursTrip() { timeTable = departures(at("8:10"), at("8:20"), at("8:30")); itineraries = new ItineraryService(timeTable); List<LocalTime> proposedDepartures = itineraries.findNextDepartures(at("8:50"), "Hornsby", "Central"); assertThat(proposedDepartures).isEmpty(); } }
#1 Create a dummy timetable for testing purposes
#2 A fake timetable that returns a hard-coded set of departure times.
#3 Call the ItineraryService to find the next departure times
#4 Check with the expected departure times
#5 A slightly more sophisticated test that checks that we return no more than twice.
#6 An edge-case test to check that no times are returned after the last departure time
With the itinerary service complete, the When step is now operational. It’s time to move on to the Given step:
Given the T1 train to Central leaves Hornsby at 8:02, 8:15, 8:21
This step needs to prepare the TimeTable that the itinerary service uses. Although they could use a dummy timetable, BDD scenarios like this one generally want to verify that all the system components work together as they should.
“It looks like we need to prepare the actual timetable data in this step. What would that look like?” wonders Tess.
“How about we create a CanScheduleServices
interface with a scheduleService method to represent this ability? We could add also add it to the TimeTable interface, but it feels like a separate concern” suggests Dave. “Something like this:”
public interface CanScheduleServices{ void scheduleService(String line, List<LocalTime> departingAt, String departure, String destination); }
Tess refactors the glue code for the Given step to use this method:
InMemoryTimeTable timeTable = new InMemoryTimeTable(); ItineraryService itineraryService = new ItineraryService(timeTable); @Given("the (.*) train to (.*) leaves (.*) at (.*)") public void theTrainLeavesAt(String line, String from, String to, String departingAt) { List<LocalTime> departureTimes = localTimesFrom(departingAt); timeTable.scheduleService(line, departureTimes, from, to); } private List<LocalTime> localTimesFrom(String listOfDepartureTimes) { return stream(listOfDepartureTimes.split(",")) .map(String::trim) .map(LocalTime::parse) .collect(Collectors.toList()); }
#1 Schedule the departure time for a specific linbe
#2 A utility method to convert the text in the Gherkin scenario into a list of LocalTime objects
“Now we need to write a class that implements both the TimeTable interface and the CanScheduleService interface”.
Once again, the pair use a test-first strategy to imagine and implement a TimeTable class. They decide to start with a simple implementation called InMemoryTimeTable. They start off with an empty implementation like this one:
public class InMemoryTimeTable implements TimeTable, CanScheduleServices { @Override public void scheduleService(String line, List<LocalTime> departingAt, String departure, String destination) {} @Override public List<String> findLinesThrough(String from, String to) { return null; } @Override public List<LocalTime> getDepartures(String lineName, String from) { return null; } }
Thanks to the modular design they chose, it’s easier to evolve this implementation later on, or even replace it with a totally different one.
The first test focuses on scheduling services, and looks like this:
@DisplayName("When scheduling train services") class WhenRecordingTrainSchedules { // Given InMemoryTimeTable timeTable = new InMemoryTimeTable(); #1 @Test @DisplayName("We can schedule a trip with a single scheduled time") void tripWithOneScheduledTime() { // When timeTable.scheduleService("T1", LocalTimes.at("09:15"), #2 "Hornsby", "Central"); // Then assertThat(timeTable.getDepartures("T1", "Hornsby")).hasSize(1); } }
#1 Create a new timetable
#2 Schedule a service with a single departure time
#3 Check the scheduled departure times
This test leads the pair to add some data structure to the InMemoryTimeTable
class. They decide to store the scheduled trips in a map, indexed by trip name.
public class InMemoryTimeTable implements TimeTable, CanScheduleServices { private Map<String, ScheduledService> schedules = new HashMap<>(); #1 @Override public void scheduleService(String line, List<LocalTime> departingAt, String from, String to) { schedules.put(line, new ScheduledService(from, to, departingAt)); #2 } }
#1 Store scheduled services in a map indexed by line name
#2 Record the scheduled service
They also decide to represent scheduled services as a domain class, like the one shown below:
public class ScheduledService { private final String departure; private final String destination; private final List<LocalTime> departureTimes; public ScheduledService(String from, String to, List<LocalTime> at) { this.departure = from; this.destination = to; this.departureTimes = at; } … }
This is enough to make the code pass. It’s simple enough, and they decide that no refactoring is necessary yet.
Now that they’re happy with the code, they continue to explore the timetable behavior, checking describing what happens when you schedule several departure times, and when you add more than one line. Each time they repeat the cycle, writing a small test, making it fail, and then reviewing their code to look for potential improvements. After a few more passing tests, they’re satisfied that line scheduling works correctly.
“We’re done, right?”, says Dave.
“Not that fast”, says Tess. We almost forgot about the TimeTable interface methods.”
Tess is right. This is enough for the Given step to work, but not for the scenario as a whole. Their next job is to write a test that explores the findLinesThrough() and getDepartures() methods.
“Let’s start with the simple case, of finding a line that goes through two stations”, proposes Dave:
@Test @DisplayName("When querying train services") class WhenQueryingTrainServices { // Given InMemoryTimeTable timeTable = new InMemoryTimeTable(); @Test @DisplayName("We can ask which lines go through any two stations") void queryLinesThroughStations() { // When timeTable.scheduleService("T1", LocalTimes.at("09:15"), "Hornsby", "Central"); // Then assertThat(timeTable.findLinesThrough("Hornsby", "Central")).hasSize(1); } } Their initial implementation of the findLinesThrough() method looks like this: @Override public List<String> findLinesThrough(String from, String to) { schedules.entrySet() .stream() .filter(line -> (line.getValue().getDeparture().equals(from) && line.getValue().getDestination().equals(to))) .map(Map.Entry::getKey) .collect(Collectors.toList()); }
This makes the test pass, but Dave is unconvinced. “It’s not the most readable code in the world”, he comments. “Let’s see if we can refactor it to make it a bit easier to follow.”
“Maybe we could tidy up the filtering logic”, suggests Tess. “What we’re trying to do is find the line or lines that go through the two stations we provide, in the right direction.”
“What if we wrote that?”, says Dave. He tinkers with the code, and comes up with a couple of new methods which allow him to refactor the linesGoThrough() method into something a little more readable:
private Set<String> lineNames() { return schedules.keySet(); } #1 private boolean lineGoesThrough(String line, String from, String to){ #2 return schedules.getOrDefault(line, ScheduledService.NO_SERVICE) .goesBetween(from,to); } @Override public List<String> findLinesThrough(String from, String to) { return lineNames().stream() .filter(line -> lineGoesThrough(line, from, to)) .collect(Collectors.toList()); }
#1 Find the names of all the scheduled lines
#2 A convenience method to check whether a given scheduled line goes between two stations
This also leads him to refactor the ScheduledService class:
public class ScheduledService { private final String departure; private final String destination; private final List<LocalTime> departureTimes; public static ScheduledService NO_SERVICE #1 = new ScheduledService("","", Lists.emptyList()); public ScheduledService(String from, String to, List<LocalTime> at) {…} public List<LocalTime> getDepartureTimes() { return departureTimes; } public boolean goesBetween(String from, String to) { #2 return departure.equals(from) && destination.equals(to); } }
#1 A constant value representing a service with no departure times
#2 A convenience method to make it easier to check that a given service goes between two stations
Once they make this one pass, they add a new test that illustrates how to get the departure times of a given line:
@Test @DisplayName("Each line can have a number of departure times") void trainLinesHaveMoreThanOneDepartureTime() { // When timeTable.scheduleService("T1", LocalTimes.at("09:15","09:45"), "Hornsby", "Central"); // Then assertThat(timeTable.getDepartures ( "T1", "Hornsby")).hasSize(2); }
After half a dozen small tests like this, they end up with the InMemoryTimeTable class you can see in Listing 6. You can also see the full test classes on Github.
Listing 6. The completed InMemoryTimeTable class
package manning.bddinaction.timetables; import java.time.LocalTime; import java.util.*; import java.util.stream.Collectors; public class InMemoryTimeTable implements TimeTable, CanScheduleServices { private Map<String, ScheduledService> schedules = new HashMap<>(); @Override public void scheduleService(String line, List<LocalTime> departingAt, String from, String to) { schedules.put(line, new ScheduledService(from, to, departingAt)); } private Set<String> lineNames() { return schedules.keySet(); } private boolean lineGoesThrough(String line, String from, String to) { return schedules.getOrDefault(line, ScheduledService.NO_SERVICE) .goesBetween(from,to); } @Override public List<String> findLinesThrough(String from, String to) { return lineNames().stream() .filter(line -> lineGoesThrough(line, from,to)) .collect(Collectors.toList()); } @Override public List<LocalTime> getDepartures(String lineName, String from) { if (!schedules.containsKey(lineName)) { throw new UnknownLineException("No line found: " + lineName); } return schedules.get(lineName).getDepartureTimes(); } }
Demonstrate: tests as living documentation
Once a feature has been implemented, you should be able to run your tests and see passing acceptance criteria among the pending ones (see figure 4). When you’re applying practices like BDD, this result does more than tell you that your application satisfies the business requirements. A passing acceptance test is also a concrete measure of progress. An implemented test either passes or fails. Ideally, if all of the acceptance criteria for a feature have been automated and run successfully, you can say that this feature is finished and ready for production.
Figure 4. The passing test should now appear in the test reports
More than evaluating the quality of your application, the state of the tests gives a clear indication of where it’s at in the development progress. The proportion of passing tests compared to the total number of specified acceptance criteria gives a good picture of how much work has been done this far and how much remains. In addition, by tracking the number of completed automated acceptance tests against the number of pending tests, you can get an idea of the progress you’re making over time.
When you write tests in this narrative style, another benefit emerges. Each automated acceptance test becomes a documented, worked example of how the system can be used to solve a particular business requirement. And when the tests are web tests, the worked examples are illustrated with screenshots taken along the way.
Maintenance
In many organizations, the developers who worked on the initial project don’t maintain the application once it goes into production. Instead, the task is handed over to a maintenance or BAU (Business as Usual) team. In this sort of environment, executable specifications and living documentation are a great way to streamline the hand-over process, as they provide a set of worked examples of the application’s features and illustrations of the code that supports these features.
Executable specifications also make it much easier for maintenance teams to implement changes or bug fixes. Let’s see how this works with a simple example. Suppose that users have requested to be informed about the next four trains that are due to arrive, and not only the next two, as is currently the case.
The scenario related to this requirement is as follows:
Scenario: Next train going to the requested destination on the same line Given the T1 train to Central leaves Hornsby at 08:02, 08:15, 08:21 When Travis want to travel from Hornsby to Chatswood at 08:00 Then he should be told about the trains at: 08:02, 08:15
This scenario expresses your current understanding of the requirement: the application currently behaves like this, and you’ve automated acceptance criteria and unit tests to prove it.
The new user request has changed all of this. The scenario now should be something like this:
Scenario: Next train going to the requested destination on the same line Given the T1 train to Chatswood leaves Hornsby at 08:02, 08:15, 08:21, 8:34, 8:45 When Travis want to travel from Hornsby to Chatswood at 08:00 Then he should be told about the trains at: 08:02, 08:15, 08:21, 08:34
When you run this new scenario, it fails (see figure 5). This is good! It demonstrates that the application doesn’t do what the requirements ask of it. Now you have a starting point for implementing this modification.
Figure 5. A failing acceptance criterion illustrates a difference between what the requirements ask for and what the application currently does.
From here, you can use the unit tests to isolate the code that needs to be changed. You’ll update the “should propose the next two trains” unit test to reflect the new acceptance criterion:
@Test @DisplayName("should propose the next 4 trains") void tripWithSeveralScheduledTimes() { timeTable = departures( at("8:10"),at("8:20"),at("8:30"),at("8:45"),at("8:45")); #1 itineraries = new ItineraryService(timeTable); List<LocalTime> proposedDepartures = itineraries.findNextDepartures(at("8:05"), "Hornsby", "Central"); assertThat(proposedDepartures) .containsExactly(at("8:10"), at("8:20"), at("8:30"),at("8:45")); #2 }
#1 The pretend service now returns more trips.
#2 You now expect the itinerary service to return four times.
This, in turn, helps you isolate the code that needs to change in the ItineraryService
class. From here, you’ll be in a much better position to update the code correctly.
For larger changes, more work is obviously involved, but the principle remains the same for modifications of any size. If the change request is a modification of an existing feature, you need to update the automated acceptance criteria to reflect the new requirement. If the change is a bug fix that your current acceptance criteria didn’t catch, then you need to write new automated acceptance criteria to reproduce the bug, then fix the bug, and finally use the acceptance criteria to demonstrate that the bug has been resolved. If the change is big enough to make existing acceptance criteria redundant, you can delete the old acceptance criteria and write new ones.
That’s all for this article.
If you want to learn more about the book, you can check it out on our browser-based liveBook reader here.
[1]If Java isn’t your cup of tea, don’t worry; the code samples are designed to be readable by anyone with some programming background.
[2]Maven (http://maven.apache.org/) is a widely used build tool in the Java world.
[3]See the Serenity BDD site (http://serenity-bdd.info) for more details about this library.
[4]The source for this article on GitHub is at https://github.com/bdd-in-action/chapter-2.
[5]If you’re not a regular Maven user, Maven first downloads the libraries it needs to work with—this may take some time, but you’ll only need to do it once.