Angular & Spring Boot - Using Serenity BDD for Integration Testing

Written by andreistefanwork | Published 2022/05/24
Tech Story Tags: integration-testing | angular | spring-boot | serenity | software-testing | learning | learning-to-code | serenity-bdd

TLDRThe 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 Serenity BDD library, write an integration test that ensures the application behaves correctly. The complete project code is available on [GitHub](https://github.com/andreistefanwork/angular-spring-integration-test)via the TL;DR App

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 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.

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 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();
    }
}

Building the frontend

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.

  • debounceTime - emits a value from the source Observable only after a particular time span has passed without another source emission
  • distinctUntilChanged - emits all values pushed by the source observable if they are distinct in comparison to the last value the result observable emitted
  • switchMap - projects each source value to an Observable which is merged in the output Observable, emitting values only from the most recently projected Observable
  • shareReplay - share source and replay specified number of emissions on subscription

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 @Managed annotation, Serenity will inject an instance with the default configuration into browser
  • Inside 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:

  • ‘given’ statement will attempt to open the browser on the age prediction page.
  • ‘when’ statement will get a handle on the <input> and type the text “Andrei”.
  • ‘then’ statement will evaluate the 4 statements:
    • 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*");
    }
}

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


Written by andreistefanwork | 🌎 Senior Web3 Full-Stack Engineer • Contractor • Freelancer | Blockchain, DeFi, Crypto enthusiast
Published by HackerNoon on 2022/05/24