Introduction Building modern web apps using Angular and Spring Boot combo is very popular among both large and small enterprises. Angular provides all necessary tools for building a robust, fast, and scalable frontend, while Spring Boot accomplishes the same for the backend, without the hassle of configuring and maintaining a web application server. Making sure that all of the software components that comprise the final product work in unison, they must be tested together. This is where comes in. Serenity BDD is an that helps with writing cleaner and more maintainable automated acceptance and regression tests. integration testing with Serenity BDD open-source library BDD - Behaviour-Driven Development is a testing technique that involves expressing how an application should behave in a simple business-focused language. Goal The goal of this article is to build a simple web application that tries to predict the age of a person, given their name. Then, using the Serenity BDD library, write an integration test that ensures the application behaves correctly. Building the Web Application First, the focus will be on the Spring Boot backend. A GET API endpoint will be exposed using a Spring RestController. When the endpoint is called with a person's name, it will return the predicted age for that name. The actual prediction will be handled by . agify.io Next, an Angular application that presents the user with a text input will be implemented. When a name is typed into the input, an HTTP GET request will be fired to the backend for fetching the age prediction. The app will then take the prediction, and display it to the user. The complete project code for this article is available on GitHub Building the backend The age prediction model will be defined first. It will take the form of a Java record with a and an . An empty age prediction will also be defined here: name age AgePrediction.java public record AgePrediction(String name, int age) { private AgePrediction() { this("", 0); } public static AgePrediction empty() { return new AgePrediction(); } } The RestController handles HTTP calls to . It defines a GET method that receives a name and reaches out to to fetch the age prediction. The method is annotated with to allow requests from Angular. If the parameter is not provided, the method simply returns an empty age prediction. /age/prediction api.agify.io @CrossOrigin name To make the actual call for the prediction, Spring’s REST Client — RestTemplate will be used: AgePredictionController.java @RestController @RequestMapping("/age/prediction") @RequiredArgsConstructor public class AgePredictionController { private final static String API_ENDPOINT = "https://api.agify.io"; private final RestTemplate restTemplate; /** * Tries to predict the age for the provided name. * * If name is empty, an empty prediction is returned. * * @param name used for age prediction * @return age prediction for given name */ @CrossOrigin(origins = "http://localhost:4200") @GetMapping public AgePrediction predictAge(@RequestParam(required = false) String name) { if (StringUtils.isEmpty(name)) { return AgePrediction.empty(); } HttpHeaders headers = new HttpHeaders(); headers.set(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); HttpEntity<?> entity = new HttpEntity<>(headers); return restTemplate.exchange(buildAgePredictionForNameURL(name), HttpMethod.GET, entity, AgePrediction.class).getBody(); } private String buildAgePredictionForNameURL(String name) { return UriComponentsBuilder .fromHttpUrl(API_ENDPOINT) .queryParam("name", name) .toUriString(); } } Building the frontend The age prediction model will be defined as an interface with a and an : name age age-prediction.model.ts export interface AgePredictionModel { name: string; age: number; } The web page will consist of a text where users will type the name to be used for the age prediction, and two elements where the name and predicted age will be displayed. <input> <h3> When users type into the , the text will be passed to the typescript class via function. <input> onNameChanged($event) Displaying and predicted is handled by subscribing to observable. name age agePrediction$ app.component.html <div> <label>Enter name to get age prediction: </label> <input id="nameInput" type="text" (input)="onNameChanged($event)"/> </div> <div> <h3> Name: <span id="personName">{{(agePrediction$ | async).name}}</span> </h3> </div> <div> <h3> Age: <span id="personAge">{{(agePrediction$ | async).age}}</span> </h3> </div> As for the Angular component, it will be called when changes occur on the via function . The event is transformed into an observable named , that is piped to fire an HTTP GET to the backend with the most recent name. This is achieved by making use of the Subject , and RxJs operators debounceTime, distinctUntilChanged, switchMap, shareReplay. <input> onNameChanged($event) agePrediction$ nameSubject - emits a value from the source Observable only after a particular time span has passed without another source emission debounceTime - emits all values pushed by the source observable if they are distinct in comparison to the last value the result observable emitted distinctUntilChanged - projects each source value to an Observable which is merged in the output Observable, emitting values only from the most recently projected Observable switchMap - share source and replay specified number of emissions on subscription shareReplay app.component.ts @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit { static readonly AGE_PREDICTION_URL = 'http://localhost:8080/age/prediction'; agePrediction$: Observable<AgePredictionModel>; private nameSubject = new Subject<string>(); constructor(private http: HttpClient) { } ngOnInit() { this.agePrediction$ = this.nameSubject.asObservable().pipe( debounceTime(300), distinctUntilChanged(), switchMap(this.getAgePrediction), shareReplay() ); } /** * Fetches the age prediction model from our Spring backend. * * @param name used for age prediction */ getAgePrediction = (name: string): Observable<AgePredictionModel> => { const params = new HttpParams().set('name', name); return this.http.get<AgePredictionModel>(AppComponent.AGE_PREDICTION_URL, {params}); } onNameChanged($event) { this.nameSubject.next($event.target.value); } } Age prediction page preview: Writing the integration test As the first step for testing the web application, an abstract test class is created to encapsulate the logic needed in Serenity tests: Actor represents the person or system using the application under test - here simply named tester WebDriver is an interface used to control the web browser. By specifying annotation, Serenity will inject an instance with the default configuration into @Managed browser Inside method, the base URL used for all tests is configured in Serenity’s EnvironmentVariables. This is meant to avoid repeating the protocol, host and port for each test page setBaseUrl() AbstractIntegrationTest.java public abstract class AbstractIntegrationTest { @Managed protected WebDriver browser; protected Actor tester; private EnvironmentVariables environmentVariables; @BeforeEach void setUp() { tester = Actor.named("Tester"); tester.can(BrowseTheWeb.with(browser)); setBaseUrl(); } private void setBaseUrl() { environmentVariables.setProperty(WEBDRIVER_BASE_URL.getPropertyName(), "http://localhost:4200"); } } To test the age prediction page, a new IndexPage class inheriting from PageObject (representation of a page in the browser) is created. The URL of the page, relative to the base URL specified previously, is defined using annotation. @DefaultUrl HTML elements present on the page are fluently defined using Serenity Screenplay. IndexPage.java @DefaultUrl("/") public class IndexPage extends PageObject { public static final Target NAME_INPUT = the("name input").located(By.id("nameInput")); public static final Target PERSON_NAME = the("name header text").located(By.id("personName")); public static final Target PERSON_AGE = the("age header text").located(By.id("personAge")); } Finally, writing the integration test implies a class inheriting from the AbstractIntegrationTest, annotated with JUnit’s and Serenity’s JUnit 5 extension. The will be injected by Serenity at test runtime. In BDD fashion, the test is structured in given-when-then blocks. @ExtendWith indexPage Reading what the test is trying to achieve is nearly as simple as reading plain English: ‘given’ statement will attempt to open the browser on the age prediction page. ‘when’ statement will get a handle on the and type the text “Andrei”. <input> ‘then’ statement will evaluate the 4 statements: verify if the person name is visible on the page <h3> verify if the person name displayed on the page is the expected one verify if the person age is visible on the page <h3> verify if the person age is a number (not checking against a fixed age, because the age prediction may change) accommodates a slower backend response by waiting for 5 seconds before passing/failing the test condition. eventually IndexPageTest.java @ExtendWith(SerenityJUnit5Extension.class) public class IndexPageTest extends AbstractIntegrationTest { private static final String TEST_NAME = "Andrei"; private IndexPage indexPage; @Test public void givenIndexPage_whenUserInputsName_thenAgePredictionIsDisplayedOnScreen() { givenThat(tester).wasAbleTo(Open.browserOn(indexPage)); when(tester).attemptsTo(Enter.theValue(TEST_NAME).into(NAME_INPUT)); then(tester).should( eventually(seeThat(the(PERSON_NAME), isVisible())), eventually(seeThat(the(PERSON_NAME), containsText(TEST_NAME))), eventually(seeThat(the(PERSON_AGE), isVisible())), eventually(seeThat(the(PERSON_AGE), isANumber())) ); } private static Predicate<WebElementState> isANumber() { return (htmlElement) -> htmlElement.getText().matches("\\d*"); } } Summary The article briefly presented how Serenity BDD can be used to implement integration tests for a modern web application. The amount of configuration required to execute the tests is kept to a minimal, and the resulting code for testing web pages is such a pleasure to read, to the point that it makes you wonder how does it even work! I am not sponsored by or have received any compensation from any of the products/services/companies listed above. This article is solely for informational purposes. References https://serenity-bdd.github.io/theserenitybook/latest/index.html https://medium.com/javascript-scene/behavior-driven-development-bdd-and-functional-testing-62084ad7f1f2 https://github.com/serenity-bdd/serenity-core https://agify.io/ https://rxjs.dev/api/operators/