Spring Boot 4 with TestNG

This is one project you can copy and run. It uses Spring Boot 4.1.2, TestNG 7.12.0, and Java 17. mvn test runs 7 tests.

The same ideas on older Spring Boot are still in the original posts: how to start, profiles, application.properties, and JSON test data.

pom.xml
src/main/java/io/lenar/examples/spring/TestApplication.java
src/test/java/io/lenar/examples/spring/SpringTestBase.java
src/test/java/io/lenar/examples/spring/ContextLoadsTest.java
src/test/java/io/lenar/examples/spring/ProfileTest.java
src/test/java/io/lenar/examples/spring/ApplicationPropertiesTest.java
src/test/java/io/lenar/examples/spring/TestDataTest.java
src/test/java/io/lenar/examples/spring/props/Props.java
src/test/java/io/lenar/examples/spring/model/User.java
src/test/java/io/lenar/examples/spring/testdata/TestData.java
src/test/resources/application.properties
src/test/resources/application-qa.properties
src/test/resources/application-production.properties
src/test/resources/test-users.json

Props and TestData live under src/test/java, in the package scanned by TestApplication. Spring creates them during tests. They are not in the production jar.

Setup

spring-boot-starter-test brings JUnit 6. Exclude it so this suite is TestNG only. spring-boot-starter does not bring Jackson. Boot 4’s JsonMapper comes from spring-boot-starter-jackson, in the package tools.jackson, not com.fasterxml.jackson.

Boot 4.1.2 uses Surefire 3.5.6. That Surefire does not run TestNG unless surefire-testng is on the plugin classpath. Without it, mvn test prints Tests run: 0 and still says BUILD SUCCESS. ${maven-surefire-plugin.version} is not declared in this pom.xml. spring-boot-starter-parent inherits it from spring-boot-dependencies 4.1.2, where it is 3.5.6.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.2</version>
    </parent>

    <groupId>io.lenar.examples</groupId>
    <artifactId>spring-boot-testng</artifactId>
    <version>0.0.1</version>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jackson</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.jupiter</groupId>
                    <artifactId>junit-jupiter</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.testng</groupId>
            <artifactId>testng</artifactId>
            <version>7.12.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <dependencies>
                    <dependency>
                        <groupId>org.apache.maven.surefire</groupId>
                        <artifactId>surefire-testng</artifactId>
                        <version>${maven-surefire-plugin.version}</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>
</project>

The application class is there so the tests have a context to start. There is no web server in this project.

package io.lenar.examples.spring;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TestApplication {

    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }
}

TestNG does not start Spring. Extend AbstractTestNGSpringContextTests and put @SpringBootTest on the same base class. Name test classes *Test. Surefire does not pick up *IT unless you configure Failsafe.

package io.lenar.examples.spring;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.testng.AbstractTestNGSpringContextTests;

@SpringBootTest(classes = TestApplication.class)
public abstract class SpringTestBase extends AbstractTestNGSpringContextTests {
}
package io.lenar.examples.spring;

import org.testng.annotations.Test;

public class ContextLoadsTest extends SpringTestBase {

    @Test
    public void contextLoads() {
    }
}

Create src/test/resources/application.properties now. The Application properties section shows the finished file.

spring.profiles.active=qa

Props, added in the next section, needs host and username in every test context. The qa profile supplies them. If you delete spring.profiles.active and leave Props on the classpath, context startup fails with Could not resolve placeholder 'host'.

Profiles

Profile files also live under src/test/resources. They do not affect a running application.

application-qa.properties

host=www.qahost.com
username=qaUsername

application-production.properties

host=www.productionhost.com
username=productionUsername
package io.lenar.examples.spring.props;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class Props {

    private final String host;
    private final String username;

    public Props(@Value("${host}") String host, @Value("${username}") String username) {
        this.host = host;
        this.username = username;
    }

    public String getHost() {
        return host;
    }

    public String getUsername() {
        return username;
    }
}

@ActiveProfiles("qa") pins this class to qa. TestNG creates the test instance, then Spring fills @Autowired fields, so field injection on the test class is the straightforward option.

package io.lenar.examples.spring;

import io.lenar.examples.spring.props.Props;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles;
import org.testng.Assert;
import org.testng.annotations.Test;

@ActiveProfiles("qa")
public class ProfileTest extends SpringTestBase {

    @Autowired
    private Props props;

    @Test
    public void qaProfileIsActive() {
        Assert.assertEquals(props.getHost(), "www.qahost.com");
        Assert.assertEquals(props.getUsername(), "qaUsername");
    }
}

@ActiveProfiles wins over spring.profiles.active. With the annotation in place, mvn test -Dspring.profiles.active=production still runs this class on qa, and the assertion above passes.

Remove @ActiveProfiles when the same class should follow the command line. Then mvn test -Dspring.profiles.active=production loads application-production.properties. The assertion as written expects www.qahost.com, so it fails until you change the expected host. That failure is the signal that production was selected: the message is expected [www.qahost.com] but found [www.productionhost.com].

Application properties

src/test/resources/application.properties is now the whole file below. Keep spring.profiles.active=qa. The two new keys are what this section tests.

spring.profiles.active=qa
testData.users.file=test-users.json
site.url=https://lenar.io

A missing key fails context startup. A typo in the name should fail there, not as a null assertion in the test.

package io.lenar.examples.spring;

import org.springframework.beans.factory.annotation.Value;
import org.testng.Assert;
import org.testng.annotations.Test;

public class ApplicationPropertiesTest extends SpringTestBase {

    @Value("${testData.users.file}")
    private String usersFile;

    @Value("${site.url}")
    private String siteUrl;

    @Test
    public void readsUsersFile() {
        Assert.assertEquals(usersFile, "test-users.json");
    }

    @Test
    public void readsSiteUrl() {
        Assert.assertEquals(siteUrl, "https://lenar.io");
    }
}

JSON test data

testData.users.file is the classpath name. Put the file at src/test/resources/test-users.json.

[
  {
    "firstName": "John",
    "lastName": "Lennon",
    "email": "mail@john.lennon.com"
  },
  {
    "firstName": "Paul",
    "lastName": "McCartney",
    "email": "mail@paul.mccartney.com"
  },
  {
    "firstName": "Ringo",
    "lastName": "Starr",
    "email": "mail@ringo.starr.com"
  },
  {
    "firstName": "George",
    "lastName": "Harrison",
    "email": "mail@george.harrison.com"
  }
]

Jackson 3 reads the component names from the record, so this type needs no annotations.

package io.lenar.examples.spring.model;

public record User(String firstName, String lastName, String email) {
}

Inject the JsonMapper bean from spring-boot-starter-jackson. getResourceAsStream returns null when the file is missing, so fail the test instead of returning a null list.

package io.lenar.examples.spring.testdata;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;

import io.lenar.examples.spring.model.User;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import tools.jackson.databind.json.JsonMapper;

@Component
public class TestData {

    private final JsonMapper jsonMapper;
    private final String usersFile;

    public TestData(JsonMapper jsonMapper, @Value("${testData.users.file}") String usersFile) {
        this.jsonMapper = jsonMapper;
        this.usersFile = usersFile;
    }

    public List<User> getUsers() {
        try (InputStream input = getClass().getResourceAsStream("/" + usersFile)) {
            if (input == null) {
                throw new IllegalStateException("Classpath resource not found: " + usersFile);
            }
            String json = new String(input.readAllBytes(), StandardCharsets.UTF_8);
            return List.of(jsonMapper.readValue(json, User[].class));
        } catch (IOException e) {
            throw new IllegalStateException("Could not read " + usersFile, e);
        }
    }
}
package io.lenar.examples.spring;

import io.lenar.examples.spring.model.User;
import io.lenar.examples.spring.testdata.TestData;

import org.springframework.beans.factory.annotation.Autowired;
import org.testng.Assert;
import org.testng.annotations.Test;

public class TestDataTest extends SpringTestBase {

    @Autowired
    private TestData testData;

    @Test
    public void loadsFourUsers() {
        Assert.assertEquals(testData.getUsers().size(), 4);
    }

    @Test
    public void ringoIsPresent() {
        User ringo = new User("Ringo", "Starr", "mail@ringo.starr.com");
        Assert.assertTrue(testData.getUsers().contains(ringo));
    }

    @Test
    public void elvisIsAbsent() {
        User elvis = new User("Elvis", "Presley", "mail@elvis.presley.com");
        Assert.assertFalse(testData.getUsers().contains(elvis));
    }
}

Run

From the project root:

mvn test

The result is Tests run: 7, Failures: 0. That is one context test, one profile test, two property tests, and three JSON tests.

Tests run: 0 with BUILD SUCCESS means surefire-testng is missing. package tools.jackson.databind.json does not exist means spring-boot-starter-jackson is missing. Could not resolve placeholder 'host' means spring.profiles.active=qa was removed while Props is still on the test classpath.


You may also find these posts interesting: