1. OVERVIEW

Your organization implemented and deployed Spring Boot applications to send emails from Thymeleaf templates.

Let’s say they include reports with confidential content, intellectual property, or sensitive data. Is your organization testing these emails?

How would you verify these emails are being sent to the expected recipients?

How would you assert these emails include the expected data, company logo, and/or file attachments?

This blog post shows you how to write integration tests with GreenMail and Jsoup for Spring Boot applications that send emails.

Integration tests with GreenMail and Jsoup Integration tests with GreenMail and Jsoup

1.1. About GreenMail and Jsoup

GreenMail is a Java-based suite of email servers supporting SMTP, POP3, and IMAP protocols mainly used for testing purposes.

Jsoup is a Java library to parse, find, extract, manipulate, and clean up HTML content.

Both of them are Open Source projects.

2. MAVEN DEPENDENCIES

This blog post uses Java 11 and Spring Boot 2.7.15, which brings in JUnit 5.8 dependencies.

Let’s continue with the relevant Maven dependencies:

pom.xml:

<properties>
  <greenmail.version>1.6.14</greenmail.version>
  <commons-email.version>1.5</commons-email.version>
  <jsoup.version>1.16.1</jsoup.version>
</properties>
...
<dependencies>
  <dependency>
    <groupId>com.icegreen</groupId>
    <artifactId>greenmail-junit5</artifactId>
    <version>${greenmail.version}</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-email</artifactId>
    <version>${commons-email.version}</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>${jsoup.version}</version>
    <scope>test</scope>
  </dependency>
...
</dependencies>

You would still need spring-boot-starter-test and rest-assured dependencies, but they are not covered in this blog post.

We use greenmail-junit5 dependency because Spring Boot 2.7 brings in JUnit 5. greenmail-junit5 helps with starting/stopping an embedded SMTP server to send and receive emails during the integration tests’ execution.

As mentioned earlier jsoup dependency takes care of parsing, extracting, and selecting data from HTML content.

And lastly, we include commons-email dependency to use utility classes around Java’s MimeMessage.

3. TESTS’ JavaMailSender CONFIGURATION

Instead of using the main application.yml’s spring.mail properties with Gmail or AWS SES configuration, let’s use a file just for testing purposes with GreenMail configuration.

application-test.yml:

spring:
  mail:
    host: localhost
    port: ${green-mail-port}
    username: spring
    password: boot
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            required: false

The email server will run on localhost. The port is not hard-coded, it will be replaced later on.

The username/password credentials are spring/boot, it won’t require TLS.

The integration test classes would need to use the test Spring profile to use this properties file.

4. Thymeleaf EMAIL HTML TEMPLATE

This blog post uses the same code base that sending emails with Spring Boot and Thymeleaf use.
However, we had to make some updates to the Thymeleaf HTML template so that we could select HTML tags using Jsoup to verify their content more easily.

We set the id attribute to some HTML tags, for instance:

-  <p>Hello <span th:text="${name}"></span>,</p>
+  <p id="welcome">Hello <span th:text="${name}"></span>,</p>


-  <p>Here is the Sales Report for <span th:text="${#temporals.format(reportDate, 'yyyy-MM-dd')}">2023-09-20</span>:</p>
+  <p id="summary">Here is the Sales Report for <span th:text="${#temporals.format(reportDate, 'yyyy-MM-dd')}">2023-09-20</span>:</p>


-      <tr style="background:#dddddd;">
+      <tr id="totalSales" style="background:#dddddd;">


-    <img th:src="|cid:${imageCompanyLogo}|" />
+    <img id="logo" th:src="|cid:${imageCompanyLogo}|" />

5. INTEGRATION TEST CLASS

This is the Spring Boot-based integration test class that uses GreenMail to send and receive emails. And uses Jsoup to parse HTML content, and to select and extract data for verification.

SalesReportControllerIntegrationTest.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = Application.class, webEnvironment = WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class SalesReportControllerIntegrationTest {

  private static final int GREEN_MAIL_PORT = SocketUtils.findAvailableTcpPort();

  @LocalServerPort
  private int port;

  @RegisterExtension
  private static GreenMailExtension GREEN_MAIL = new GreenMailExtension(
      new ServerSetup(GREEN_MAIL_PORT, null, ServerSetup.PROTOCOL_SMTP))
    .withConfiguration(GreenMailConfiguration.aConfig().withUser("blah@meh.com", "spring", "boot"));

  @DynamicPropertySource
  public static void springMailProperties(DynamicPropertyRegistry registry) throws Exception {
    log.info("Setting spring.mail.port to {}", GREEN_MAIL_PORT);
    registry.add("spring.mail.port", () -> String.valueOf(GREEN_MAIL_PORT));
  }

  @BeforeEach
  public void setup() {
    RestAssured.port = this.port;
  }

  @Test
  @SuppressWarnings("serial")
  public void shouldSendSalesReportEmail() throws Exception {
    RestAssured
      .given()
        .accept(ContentType.JSON)
        .contentType(ContentType.JSON)
        .queryParam("reportDate", "2023-09-26")
      .when()
        .post("/api/sales-reports")
      .then()
        .statusCode(HttpStatus.NO_CONTENT.value());

    // Assertions
    MimeMessage actualReceivedMessage = GREEN_MAIL.getReceivedMessages()[0];
    MimeMessageParser actualParser = new MimeMessageParser(actualReceivedMessage).parse();

    log.info("{}", GreenMailUtil.getBody(actualReceivedMessage));

    MatcherAssert.assertThat(actualParser.getFrom(), Matchers.equalTo("blah-unattended-from"));
    MatcherAssert.assertThat(actualParser.getTo().size(), Matchers.equalTo(1));
    MatcherAssert.assertThat(actualParser.getSubject(), Matchers.equalTo("Sales Report for 2023-09-26"));

    Address actualEmailAddr = actualParser.getTo().iterator().next();
    MatcherAssert.assertThat(actualEmailAddr.toString(), Matchers.equalTo("blah-unattended-to"));

    Path expectedPath = Path.of(SalesReportControllerIntegrationTest.class.getClassLoader()
      .getResource("stubs/email-sales-report-plain-text.txt").toURI());
    String expectedTextPlain = new String(Files.readAllBytes(expectedPath)).replaceAll("\n|(\r\n)", System.lineSeparator());
    String actualPlainContent = actualParser.getPlainContent().replaceAll("\n|(\r\n)", System.lineSeparator());
    MatcherAssert.assertThat(actualPlainContent, Matchers.equalTo(expectedTextPlain));

    Document actualDocument = Jsoup.parse(actualParser.getHtmlContent());

    // Title
    MatcherAssert.assertThat(actualDocument.select("title").text(), Matchers.equalTo("Sales Report for 2023-09-26"));

    // Welcome
    MatcherAssert.assertThat(actualDocument.select("p#welcome").text(), Matchers.equalTo("Hello Orlando,"));

    // Summary
    MatcherAssert.assertThat(actualDocument.select("p#summary").text(),
      Matchers.equalTo("Here is the Sales Report for 2023-09-26:"));

    // Table with sales by country data
    Map<String, String> expectedSalesReport = new HashMap<>() { {
        put("US", "$3,000.00");
        put("UK", "$2,000.00");
        put("India", "$1,800.00");
    }};
    Elements actualSalesTableRows = actualDocument.select("tbody tr");
    // Don't assert the last table row, which is the total sales
    for (int i = 0; i <= actualSalesTableRows.size() - 2; i++) {
      Element actualSalesRowColumns = actualSalesTableRows.get(i);
      String actualCountryColumn = actualSalesRowColumns.select("td:eq(0)").text();
      String actualSalesColumn = actualSalesRowColumns.select("td:eq(1)").text();
      MatcherAssert.assertThat(actualSalesColumn, Matchers.equalTo(expectedSalesReport.get(actualCountryColumn)));
      expectedSalesReport.remove(actualCountryColumn);
    }
    MatcherAssert.assertThat(expectedSalesReport, IsMapWithSize.aMapWithSize(0));
    // total sales table row
    Element actualTotalSales = actualSalesTableRows.last();
    MatcherAssert.assertThat(actualTotalSales.text(), Matchers.equalTo("Total: $6,800.00"));

    // Embedded Logo
    MatcherAssert.assertThat(actualDocument.select("img#logo").attr("src"), Matchers.equalTo("cid:imageCompanyLogo"));
    MatcherAssert.assertThat(actualParser.getAttachmentList(), Matchers.hasSize(1));
    DataSource actualEmbeddedImageDS = actualParser.getAttachmentList().iterator().next();
    MatcherAssert.assertThat(actualEmbeddedImageDS.getContentType(), Matchers.equalTo("image/png"));
  }
}

Quite some code here. Let’s discern this integration test class.

You would use @ExtendWith(SpringExtension.class) because this integration test class uses JUnit 5 as a result of using Spring Boot 2.7.x.

The @LocalServerPort-annotated port variable will be set to an unused TCP port where this Spring Boot application will listen on so that the integration test methods can send HTTP requests to using RestAssured, as you can see in lines 24 and 30.

The GreenMailExtension JUnit 5 extension handles the life cycle of the GreenMail email server. It sets up, configures, starts, and stops the email server used for sending and receiving emails during the integration tests’ execution.
In our case, each integration test method, just one really, will start/stop a new server. You could also start just one and share it with all integration tests in the class by adding .withPerMethodLifecycle(false) to the extension configuration.

The other interesting piece is the GreenMail ServerSetup using a dynamic TCP port.
You want to use a dynamic port mainly for a couple of reasons:

  • to use an available TCP port that is not in use by another service, or reserved by the Operating System.
  • when running integration tests in parallel.

spring.mail.port property was set to a placeholder in application-test.yml.
The @DynamicPropertySource-annotated method sets spring.mail.port with the dynamic TCP port the email server would listen on.

Let’s now discuss the shouldSendSalesReportEmail() test method:

The RestAssured statement sends a POST request to /api/sales-reports, whose implementation sends an email.

GREEN_MAIL.getReceivedMessages()[0] returns the only email the RESTful controller implementation sent.

The test uses commons-email’s MimeMessageParser utility class that makes it easy to retrieve the email’s from, recipients, subject, and other objects to run assertions, like those from lines 46 through 48.

Next, lines 53 through 57 verifies that the text-based email content matches a predefined stub text file located in the classpath: src/test/resources/stubs/email-sales-report-plain-text.txt.

Plain text test stub email file

After that, the Jsoup.parse() method parses the HTML email content and instantiates a Jsoup Document that we’ll use to select certain HTML tags to assert against the expected data.

Take for instance this sample HTML email:

Sample HTML Email Sample HTML Email

Starting from line 62:

  • select("title").text() returns the HTML title element value.
  • select("p#welcome").text(), select("p#summary").text() returns the values for <p> elements with id equals to welcome and summary.
  • select("tbody tr") returns four table rows (excludes the table header) according to the sample HTML email.
    Lines 75 through 81 loops through the table rows except for the last one, which corresponds to the Total Sales Report row, and uses:
    • select("td:eq(0)").text(), select("td:eq(1)").text() to get the first and second columns of the current row to verify the (country, sales) pair with the expected values.
  • select("img#logo").attr("src") returns the src attribute of the img element, cid:imageCompanyLogo, which is the Content ID of the embedded image.

Integration tests with GreenMail and Jsoup Integration tests with GreenMail and Jsoup

6. CONCLUSION

GreenMail and Jsoup are great options for writing integration tests for Spring Boot applications that send emails, and to make sure the emails are sent to the right recipients with the expected content and file attachments.

You can use JUnit 5 and GreenMail to start an SMTP, POP3, and/or an IMAP email server for your integration tests to send and receive emails.

You can also use Jsoup to parse HTML emails and execute assertions against expected data in your integration tests.

Thanks for reading and as always, feedback is very much appreciated. If you found this post helpful and would like to receive updates when content like this gets published, sign up to the newsletter.

7. SOURCE CODE

Your organization can now save time and costs by purchasing a working code base, clean implementation, with support for future revisions.