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 integration testing with Serenity BDD comes in. Serenity BDD is an open-source library that helps with writing cleaner and more maintainable automated acceptance and regression tests.
BDD - Behaviour-Driven Development is a testing technique that involves expressing how an application should behave in a simple business-focused language.
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.
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
The age prediction model will be defined first. It will take the form of a Java record with a name
and an age
. An empty age prediction will also be defined here:
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 /age/prediction
. It defines a GET method that receives a name and reaches out to api.agify.io to fetch the age prediction. The method is annotated with @CrossOrigin
to allow requests from Angular. If the name
parameter is not provided, the method simply returns an empty age prediction.
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();
}
}
The age prediction model will be defined as an interface with a name
and an age
:
age-prediction.model.ts
export interface AgePredictionModel {
name: string;
age: number;
}
The web page will consist of a text <input>
where users will type the name to be used for the age prediction, and two <h3>
elements where the name and predicted age will be displayed.
When users type into the <input>
, the text will be passed to the typescript class via onNameChanged($event)
function.
Displaying name
and predicted age
is handled by subscribing to agePrediction$
observable.
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 <input>
via function onNameChanged($event)
. The event is transformed into an observable named agePrediction$
, 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 nameSubject
, and RxJs operators debounceTime, distinctUntilChanged, switchMap, 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:
As the first step for testing the web application, an abstract test class is created to encapsulate the logic needed in Serenity tests:
tester
@Managed
annotation, Serenity will inject an instance with the default configuration into browser
setBaseUrl()
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
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 @DefaultUrl
annotation.
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 @ExtendWith
and Serenity’s JUnit 5 extension. The indexPage
will be injected by Serenity at test runtime. In BDD fashion, the test is structured in given-when-then blocks.
Reading what the test is trying to achieve is nearly as simple as reading plain English:
<input>
and type the text “Andrei”.verify if the person name <h3>
is visible on the page
verify if the person name displayed on the page is the expected one
verify if the person age <h3>
is visible on the page
verify if the person age is a number (not checking against a fixed age, because the age prediction may change)
eventually
accommodates a slower backend response by waiting for 5 seconds before passing/failing the test condition.
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*");
}
}
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.