Link Search Menu Expand Document

Spring/React: Testing Pyramid - How to go beyond just unit testing, and do integration and end-to-end tests

Initially, the code bases for each of our Spring/React projects had only unit tests.

Starting in Spring 2024, we began to slowly add integration and end-to-end tests to each of these (led by the work of MS Student Andrew Peng). This is an effort to complete the testing pyramid.

This guide explains, step-by-step, how to introduce integration and end-to-end testing in to one of these code bases, including:

  • Changes to the pom.xml
  • Changes to the /resources directory
  • Services that you need to add (support code)
  • Adding the integration tests themselves
  • Adding the end-to-end tests themselves
  • Adding Github workflow(s) for the tests
  • What you need to add to the documentation to explain the new tests
  • Considerations neccessary for future work

For this tutorial we are using the proj-happycows codebase as an example.

I highly recommend reading the following article prior to getting started with this guide, as man of the new tools and techincal details are broken down and explained in the article, as opposed to repeated in this guide.

Integration and End-to-end Testing in our Stack

Step 1: Packages and Maven Profiles

We must first start by adding the neccessary dependencies and profiles to our pom.xml. You may want to use the latest versions instead.

Playwright and Wiremock dependencies:

<dependency>
  <groupId>com.microsoft.playwright</groupId>
  <artifactId>playwright</artifactId>
  <version>1.41.0</version>
  <scope>test</scope>
</dependency>

<dependency>
  <groupId>com.github.tomakehurst</groupId>
  <artifactId>wiremock-jre8-standalone</artifactId>
  <version>2.35.1</version>
</dependency>

The next thing to add is an additional plugin, maven-surefire-plugin which enables us to use the command mvn failsafe:integration-test which runs all of our integration and end-to-end tests. Some projects may already have this plugin added, please use the following/latest version if possible.

<!-- This fixes a problem as explained in this SO article:
https://stackoverflow.com/a/61936537/13960329
-->
<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.0.0-M5</version>
  <configuration>
    <!-- Activate the use of TCP to transmit events to the plugin -->
    <forkNode implementation="org.apache.maven.plugin.surefire.extensions.SurefireForkNodeFactory"/>
  </configuration>
</plugin>

Lastly we add the following two profiles, wiremock and integration. The wiremock profile is for localhost debugging of any mocked API’s and the integration profile is for running our new tests.

<!-- to run with this profile use "WIREMOCK=true mvn spring-boot:run" -->
<profile>
  <id>wiremock</id>
  <activation>
    <property>
      <name>env.WIREMOCK</name>
    </property>
  </activation>
  <properties>
    <springProfiles>wiremock,development</springProfiles>
  </properties>
  <dependencies>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</profile>

<!-- to run with this profile use "INTEGRATION=true mvn spring-boot:run" -->
<profile>
  <id>integration</id>
  <activation>
    <property>
      <name>env.INTEGRATION</name>
    </property>
  </activation>
  <properties>
    <springProfiles>integration</springProfiles>
  </properties>
  <dependencies>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>com.github.eirslett</groupId>
        <artifactId>frontend-maven-plugin</artifactId>
        <version>1.12.1</version>
        <configuration>
            <workingDirectory>frontend</workingDirectory>
            <installDirectory>${project.build.directory}</installDirectory>
        </configuration>
        <executions>
          <execution>
            <id>install node and npm</id>
            <goals>
              <goal>install-node-and-npm</goal>
            </goals>
            <configuration>
              <nodeVersion>v16.20.0</nodeVersion>
            </configuration>
          </execution>
          <execution>
            <id>npm install</id>
            <goals>
              <goal>npm</goal>
            </goals>
            <configuration>
              <arguments>ci</arguments>
            </configuration>
          </execution>
          <execution>
            <id>npm run build</id>
            <goals>
              <goal>npm</goal>
            </goals>
            <configuration>
              <arguments>run build</arguments>
            </configuration>
          </execution>
        </executions>
          </plugin>
          <plugin>
          <artifactId>maven-antrun-plugin</artifactId>
          <version>3.0.0</version>
          <executions>
            <execution>
              <phase>generate-resources</phase>
              <configuration>
                <target>
                  <copy todir="${project.build.outputDirectory}/public">
                    <fileset dir="${project.basedir}/frontend/build" />
                  </copy>
                </target>
              </configuration>
              <goals>
                <goal>run</goal>
              </goals>
            </execution>
          </executions>
        </plugin>
    </plugins>
  </build>
</profile>

You can specify the application to run in either of these profiles by appending either WIREMOCK=true or INTEGRATION=true before the Maven command you wish to do. This is equivalent to putting the variable inside your .env, but its more convenient to do it from the command line especially when changing profiles frequently.

Step 2: Spring Profiles

Now that we have added additional Maven profiles, we must add some .properties to support the Spring profiles that they depend on.

These properties files are not application generic and require some attention to detail. Under src/main/resources, both of the two new files have the same intial contents and application-development.properties with the required changes for each new file below.

Create application-wiremock.properties with the same contents as application-development.properties however you must add the following.

spring.security.oauth2.client.registration.my-oauth-provider.client-id=integrationtest
spring.security.oauth2.client.registration.my-oauth-provider.client-secret=secret
spring.security.oauth2.client.registration.my-oauth-provider.client-name=Client to Mock Oauth2 Server
spring.security.oauth2.client.registration.my-oauth-provider.provider=my-oauth-provider
spring.security.oauth2.client.registration.my-oauth-provider.scope=https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile
spring.security.oauth2.client.registration.my-oauth-provider.redirect-uri=http://localhost:8080/login/oauth2/code/my-oauth-provider
spring.security.oauth2.client.registration.my-oauth-provider.client-authentication-method=basic
spring.security.oauth2.client.registration.my-oauth-provider.authorization-grant-type=authorization_code

spring.security.oauth2.client.provider.my-oauth-provider.authorization-uri=http://localhost:8090/oauth/authorize
spring.security.oauth2.client.provider.my-oauth-provider.token-uri=http://localhost:8090/oauth/token
spring.security.oauth2.client.provider.my-oauth-provider.user-info-uri=http://localhost:8090/userinfo
spring.security.oauth2.client.provider.my-oauth-provider.user-info-authentication-method=header
spring.security.oauth2.client.provider.my-oauth-provider.user-name-attribute=sub

app.oauth.login=${OAUTH_LOGIN:${env.OAUTH_LOGIN:/oauth2/authorization/my-oauth-provider}}
app.admin.emails=admingaucho@ucsb.edu

Create application-integration.properties now with the same contents as the newly created application-wiremock.properties, and add the following line:

app.playwright.headless=${HEADLESS:${env.HEADLESS:true}}

You will also want to modify this:

spring.datasource.url=jdbc:h2:file:./target/db-development

To this:

spring.datasource.url=jdbc:h2:mem:${random.uuid}

Add the following line to application.properties.

app.oauth.login=${OAUTH_LOGIN:${env.OAUTH_LOGIN:/oauth2/authorization/google}}

Step 3: Wiremock Service

Now we are going to introduce the WiremockService. This service is used in the Wiremock profile for localhost debugging of the mocked oauth flow, and in the Integration profile in each of our end-to-end tests.

The addition of this service requires multiple new files as well as changes to a number of files in both the frontend and backend.

First, under src/test/resources/__files (create the necessary folders if they do not exist) create a file login.html with:

<html>
<body>
Login page for wiremock oauth
<br />
<form method="POST" action="/login">
    <input type="hidden" name="state" value=""/>
    <input type="hidden" name="redirectUri" value=""/>

    <p>Note: username and password are currently ignored when using wiremock</p>
    <input id=username type="text" name="username" />
    <input id=password type="password" name="password" />

    <input id=submit type="submit" value="Login" />
</form>
</body>
</html>

This HTML page serves as our ‘login’ page. At this point in time, the fields do not affect the result.

Next we’ll add the three files that make up the WiremockService.

Since this service is only used for testing purposes, we can exclude them from Jacoco and Pitest.

Add this line to the pom.xml under the Jacoco plugin:

<exclude>**/edu/ucsb/cs156/happiercows/services/wiremock/*</exclude>

And these lines under the Pitest plugin:

<param>edu.ucsb.cs156.happiercows.services.wiremock.WiremockService</param>
<param>edu.ucsb.cs156.happiercows.services.wiremock.WiremockServiceDummy</param>
<param>edu.ucsb.cs156.happiercows.services.wiremock.WiremockServiceImpl</param>

as well as

<param>edu.ucsb.cs156.happiercows.web.*</param>

Under the Pitest <excludedTestClasses>.

In order to utilize the service we have added, we need to add two application runners to _Application.java, in our case HappierCowsApplication.java.

@Autowired
WiremockService wiremockService;

@Profile("wiremock")
@Bean
public ApplicationRunner wiremockApplicationRunner() {
  return arg -> {
    log.info("wiremock mode");
    wiremockService.init();
    log.info("wiremockApplicationRunner completed");
  };
}

@Profile("development")
@Bean
public ApplicationRunner developmentApplicationRunner() {
  return arg -> {
    log.info("development mode");
    log.info("developmentApplicationRunner completed");
  };
}

The first uses our new service when in the Spring profile ‘wiremock’ and the second does not have any functionality but rather serves as an example.

Now that we have added this new service, we must update our test case parent class ControllerTestCase.java and any tests that do not use it as a parent, including a mock bean:

@MockBean
WiremockService mockWiremockService;

Next, one of the functions we desire from the ‘wiremock’ profile is the ability to click the login button on the navbar and be directed to our ‘fake’ login page. To do this we must add a field private String oauthLogin; to our SystemInfo.java so that we can use the value in our navbar. In SystemInfoServiceImpl.java we extract this value from the properties files we addeed earlier using the following annotation.

@Value("${app.oauth.login:/oauth2/authorization/google}")
private String oauthLogin;

We must also approprirately update the getSystemInfo() method as well as the SystemInfoControllerTests.java.

Now, to the frontend navbar AppNavbar.js, we’ll add a variable that extracts this value from the systemInfo passed to the navbar component, and we’ll change the href of our login button to use this variable instead of the hardcoded string we have.

var oauthLogin = systemInfo?.oauthLogin || "/oauth2/authorization/google";

For proj-happycows, we also have a login page that replaces the home page if the user is not yet logged in. We must also modify that. With these frontend changes we must also update the unit tests.

Now to test whether or not the wiremock profile works, we can deploy the application using:

WIREMOCK=true mvn spring-boot:run

and in the /frontend directory

npm start

If our login button redirects us correctly to our html page, and we are able to succesfully login with the fake admin user (admingaucho@ucsb.edu) then this step is complete.

Step 4: Integration Tests

Finally, after all that setup, we can begin working on adding some integration tests.

Note that, our goal with this guide is not to have a complete integration or end-to-end test suite, rather it is to present a baseline example of what the test suite should look like and hopefully provide enough such that any future work can continue from that point.

Before starting, you may run into a potential issue with conflicting bean definitions. This arises when Spring is attempting to auto-inject beans, but it is unable to determine which definition to use when two beans have the same signature. This currently happens in our application between the CurrentUserServiceImpl.java and MockCurrentUserServiceImpl.java. To resolve this, we must add the @Primary annotation to our CurrentUserServiceImpl class as well as change the name of the @Service() in MockCurrentUserServiceImpl from currentUser to testingUser.

Next, we’ll select a controller to begin writing integration tests for. For the proj-happycows application we will use the CommonsController.java, and we’ll add a file CommonsIT.java under a new /integration folder with a simple integration test for GET (imports excluded):

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("integration")
@Import(TestConfig.class)
@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD)
public class CommonsIT {
    @Autowired
    public CurrentUserService currentUserService;

    @Autowired
    public GrantedAuthoritiesService grantedAuthoritiesService;

    @Autowired
    CommonsRepository commonsRepository;

    @Autowired
    public MockMvc mockMvc;

    @Autowired
    public ObjectMapper mapper;

    @MockBean
    UserRepository userRepository;

    @Autowired
    private ObjectMapper objectMapper;

    @WithMockUser(roles = {"USER"})
    @Test
    public void getCommonsTest() throws Exception {
        List<Commons> expectedCommons = new ArrayList<Commons>();
        Commons Commons1 = Commons.builder().name("TestCommons1").build();
        expectedCommons.add(Commons1);

        commonsRepository.save(Commons1);
        
        MvcResult response = mockMvc.perform(get("/api/commons/all").contentType("application/json"))
                .andExpect(status().isOk()).andReturn();

        String responseString = response.getResponse().getContentAsString();
        List<Commons> actualCommons = objectMapper.readValue(responseString, new TypeReference<List<Commons>>() {
        });
        assertEquals(actualCommons, expectedCommons);
    }
}

You may want to consider writing a parent class for integration tests, like we have for unit tests, especially considering future growth of the test suite.

In order to run our new integration test, first mvn clean, then:

INTEGRATION=true mvn test-compile

Then

INTEGRATION=true mvn failsafe:integration-test

In this example, I’ve selected a somewhat basic unit test to emulate since it is good to begin with a test that is easy to debug in order to make sure that the integration test setup has been done correctly. As soon as we have a single working integration test, we can consider adding tests for more complex series of requests.

Step 5: End-to-end Tests

For end-to-end tests, we will have a parent test case class, similar to the ControllerTestCase.java for unit tests. The reason being, all of the end-to-end tests for this application will end up sharing ~50 lines of common code. We’ll call it WebTestCase.java.

@ActiveProfiles("integration")
public abstract class WebTestCase {
    @LocalServerPort
    private int port;

    @Value("${app.playwright.headless:true}")
    private boolean runHeadless;

    private static WireMockServer wireMockServer;

    protected Browser browser;
    protected Page page;

    @BeforeAll
    public static void setupWireMock() {
        wireMockServer = new WireMockServer(options()
                .port(8090)
                .extensions(new ResponseTemplateTransformer(true)));

        WiremockServiceImpl.setupOauthMocks(wireMockServer, false);

        wireMockServer.start();
    }

    @AfterAll
    public static void teardownWiremock() {
        wireMockServer.stop();
    }

    @AfterEach
    public void teardown() {
        browser.close();
    }

    public void setupUser(boolean isAdmin) {
        WiremockServiceImpl.setupOauthMocks(wireMockServer, isAdmin);

        browser = Playwright.create().chromium().launch(new BrowserType.LaunchOptions().setHeadless(runHeadless));

        BrowserContext context = browser.newContext();
        page = context.newPage();

        String url = String.format("http://localhost:%d/oauth2/authorization/my-oauth-provider", port);
        page.navigate(url);

        if (isAdmin) {
            page.locator("#username").fill("admingaucho@ucsb.edu");
        } else {
            page.locator("#username").fill("cgaucho@ucsb.edu");
        }

        page.locator("#password").fill("password");
        page.locator("#submit").click();
    }
}

Our new parent class contains all of the code necessary for setting up each end-to-end test: WireMockServer setup, Playwright browser and page setup(headless or not), and logging in either as an admin or a regular user.

With that, we can now write our first end-to-end test. The test we’ll create will test whether or not an admin can succesfully create a new commons.

Just like our integration tests, our end-to-end tests will sit in their own folder /web.

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@ActiveProfiles("integration")
@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD)
public class CommonsWebIT extends WebTestCase {
    @Test
    public void adminCreateCommonsTest() throws Exception {
        setupUser(true);

        page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Admin")).click();
        page.getByText("Create Commons").click();

        page.getByTestId("CommonsForm-name").fill("Web Test Commons");
        page.getByTestId("CommonsForm-Submit-Button").click();

        assertThat(page.getByTestId("commonsCard-name-1")).hasText("Web Test Commons");
    }
}

Now we have a simple test that tests whether or not an admin can succesfully create a commons using the Create Commons page. Luckily, the proj-happycows application has all of the fields already filled with default values, which means the minimum we have to do it fill in the name of the commons and click create.

More on various Playwright actions here: Playwright Actions

In order to run our new test, we follow the same three commands as described at the end of Step 4.

To run ‘not headless’ use:

INTEGRATION=true HEADLESS=false mvn failsafe:integration-test

Step 6: New Github Workflow

Now that we have added some integration and end-to-end tests, we must add an additional github workflow to our actions suite that runs them.

Create a file called XX-backend-integration.yml in the .github/workflows directory with the following contents, replacing XX with an appropriate, non-conflicting number. In our case, 11.

# This workflow will build a Java project with Maven
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven

name: "11-backend-integration: Java Integration tests"

on:
  workflow_dispatch:
  pull_request:
    paths: [src/**, pom.xml, lombok.config]
  push:
    branches: [ main ]
    paths: [src/**, pom.xml, lombok.config]

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
    - uses: actions/checkout@v3.5.2
    - name: Set up Java (version from .java-version file)
      uses: actions/setup-java@v3
      with:
         distribution: semeru # See: https://github.com/actions/setup-java#supported-distributions
         java-version-file: ./.java-version

    - name: Run tests with Maven
      run: INTEGRATION=true mvn -B test-compile failsafe:integration-test failsafe:verify

Step 7: Testing Documentation

The last step in introduce integration and end-to-end tests is to document the new tests. To do this, we can add a section to the readme called To Run Integration and End-to-end Tests.

This section in the README should include the three commands that we described earlier, and the instructions for running ‘not headless’.

# To Run Integration and End-to-end tests

In order to run the integration and end-to-end tests, use the following series of commands

* mvn clean
* INTEGRATION=true mvn test-compile
* INTEGRATION=true mvn failsafe:integration-test

For more information on these commands, see: https://ucsb-cs156.github.io/topics/testing/testing_integration_e2e_tests.html#running-the-integration-and-end-to-end-tests

To run a particular integration test (e.g. only HomePageWebIT.java) use -Dit.test=ClassName, for example:

* INTEGRATION=true mvn test-compile failsafe:integration-test -Dit.test=HomePageWebIT

In order to run 'not headless':

* INTEGRATION=true HEADLESS=false mvn failsafe:integration-test

Integration tests are any methods labelled with @Test annotation, that are under the /src/test/java hierarchy, and have names starting with IT (specifically capital I, capital T).

By convention, we are putting Integration tests (the ones that run with Playwright) under the package src/test/java/edu/ucsb/cs156/example/web.

Unless you want a particular integration test to also be run when you type mvn test, do not use the suffixes Test or Tests for the filename.

Note that while mvn test is typically sufficient to run tests, we have found that if you haven't compiled the test code yet, running mvn failsafe:integration-test may not actually run any of the tests.

Considerations for Future Work

This guide to adding integration and end-to-end testing to a CS156 Spring/React application was based on the proj-happycows codebase. Additional codebases may offer additional challenges and require some considerations. For example, proj-courses will need:

  • Wiremock endpoints for the UCSB API
  • Equivalent in-memory implementation of MongoDB, smiilar to the in-memory H2 for the SQL database (see: MongoDB in-memory storage engine )