Search results
Writing integration tests with GreenMail and Jsoup for Spring Boot applications that send Emails
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
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
.
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
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 withid
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
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.