jim stafford
Demonstration? Verification/Validation? Bug Detection? Design? More…?
There are many great reasons to incorporate software testing into the application lifecycle. There is no time too early to start.
Unit Testing - Integration Testing - System Testing - Acceptance Testing - More…?
It would be easy to say that our focus in this lesson will be on unit and integration testing. However, there are some aspects of system and acceptance testing that are applicable as well.
Static Analysis - Dynamic Analysis - White-box Testing - Black-box Testing
Many, many more …
In this lesson we will focus on dynamic analysis testing using both black-box interface contract testing and white-box implementation and collaboration testing.
The student will learn:
to understand the testing frameworks bundled within Spring Boot Test Starter
to leverage test cases and test methods to automate tests performed
to leverage assertions to verify correctness
to integrate mocks into test cases
to implement unit integration tests within Spring Boot
to express tests using Behavior-Driven Development (BDD) acceptance test keywords
to automate the execution of tests using Maven
to augment and/or replace components used in a unit integration test
At the conclusion of this lecture and related exercises, the student will be able to:
write a test case and assertions using "Vintage" JUnit 4 constructs
write a test case and assertions using JUnit 5 "Jupiter" constructs
leverage alternate (JUnit, Hamcrest, AssertJ, etc.) assertion libraries
implement a mock (using Mockito) into a JUnit unit test
define custom behavior for a mock
capture and inspect calls made to mocks by subjects under test
implement BDD acceptance test keywords into Mockito & AssertJ-based tests
implement unit integration tests using a Spring context
implement (Mockito) mocks in Spring context for unit integration tests
augment and/or override Spring context components using @TestConfiguration
execute tests using Maven Surefire plugin
At the heart of testing, we want to
| Figure 1. Basic Test Concepts |
Subjects can vary in scope depending on the type of our test
Unit testing will have class and method-level subjects
Integration tests can span multiple classes/components — whether
vertically (e.g., front-end request to database)
horizontally (e.g., peers)
You will see terms "unit" and "integration" used differently as we go through the testing topics and span tooling
Conceptually
unit tests dealing with one subject at a time and involve varying levels of simulation around them in order to test that subject
integration tests when multiple real components brought together to form the overall set of subjects — whether that be vertical (e.g., to the database and back) or horizontal (e.g., peer interactions) in nature.
Test Management - worry about what it takes to spin up and shutdown resources to conduct our testing
Maven refers to unit tests as anything that can be performed within a single JVM
Maven refers to integration tests as tests that require managing external resources (e.g., start/stop web server).
Maven runs these tests in different phases
unit tests execute first with the Surefire plugin
integration tests execute last with the Failsafe plugin
By default
Surefire will locate unit tests starting with "Test" or ending with "Test", "Tests", or "TestCase"
Failsafe will locate integration tests starting with "IT" or ending with "IT" or "ITCase"
neither tools like JUnit or the IDEs care how are classes are named
however to automate testing — we have to pay attention to Maven Surefire and Failsafe naming rules
Unit Test - conceptual unit test focused on a limited subject
will use the suffix "Test".
run without a Spring context and will be picked up by Maven Surefire
Unit Integration Test - conceptual integration test (vertical or horizontal) runnable within a single JVM
will use the suffix "NTest"
will likely involve a Spring context and will be picked up by Maven Surefire
External Integration Test - conceptual integration test (vertical or horizontal) requiring external resource management
will use the suffix "IT"
will always have Spring context(s) running in one or more JVMs and will be picked up by Maven Failsafe
You will see conceptual integration tests executed during Maven Surefire test phase if we can perform testing without resource management of external processes |
We want to automate tests as much as possible
can do that with many of the Spring Boot testing options
made available using spring-boot-starter-test
dependency
transitive dependencies on several powerful, state of the art as well as legacy, testing frameworks.
only used during builds and not in production
assign a scope of test
to this dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> (1)
</dependency>
1 | dependency scope is test since these dependencies are not required to run outside of build environment |
[INFO] +- org.springframework.boot:spring-boot-starter-test:jar:3.3.2:test
[INFO] | +- org.springframework.boot:spring-boot-test:jar:3.3.2:test
[INFO] | +- org.springframework.boot:spring-boot-test-autoconfigure:jar:3.3.2:test
[INFO] | +- org.springframework:spring-test:jar:6.1.11:test
[INFO] | +- org.junit.jupiter:junit-jupiter:jar:5.10.3:test
[INFO] | | +- org.junit.jupiter:junit-jupiter-api:jar:5.10.3:test
[INFO] | | +- org.junit.jupiter:junit-jupiter-params:jar:5.10.3:test
[INFO] | | \- org.junit.jupiter:junit-jupiter-engine:jar:5.10.3:test
[INFO] | +- org.assertj:assertj-core:jar:3.25.3:test
[INFO] | | \- net.bytebuddy:byte-buddy:jar:1.14.18:test
[INFO] | +- org.hamcrest:hamcrest:jar:2.2:test
[INFO] +- org.exparity:hamcrest-date:jar:2.0.8:test
[INFO] | \- org.hamcrest:hamcrest-core:jar:2.2:test
[INFO] | +- org.mockito:mockito-core:jar:5.11.0:test
[INFO] | | +- net.bytebuddy:byte-buddy-agent:jar:1.14.18:test
[INFO] | | \- org.objenesis:objenesis:jar:3.3:test
[INFO] | +- org.mockito:mockito-junit-jupiter:jar:5.11.0:test
[INFO] | +- com.jayway.jsonpath:json-path:jar:2.9.0:test
[INFO] | | \- org.slf4j:slf4j-api:jar:2.0.13:compile
[INFO] | +- org.skyscreamer:jsonassert:jar:1.5.3:test
[INFO] | \- org.xmlunit:xmlunit-core:jar:2.9.1:test
[INFO] \- org.junit.vintage:junit-vintage-engine:jar:5.10.3:test
[INFO] +- org.junit.platform:junit-platform-engine:jar:1.10.3:test
[INFO] +- junit:junit:jar:4.13.2:test
...
At a high level:
spring-boot-test-autoconfigure
- contains many auto-configuration
classes that detect test conditions and configure common resources for use
in a test mode
junit
- required to run the JUnit tests
hamcrest
- required to implement Hamcrest test assertions
assertj
- required to implement AssertJ test assertions
mockito
- required to implement Mockito mocks
jsonassert
- required to write flexible assertions for JSON data
jsonpath
- used to express paths within JSON structures
xmlunit
- required to write flexible assertions for XML data
In the rest of this lesson, I will be describing how JUnit, the assertion libraries, Mockito and Spring Boot play a significant role in unit and integration testing.
test framework that has been around for many years
originated by Kent Beck and Erich Gamma during a plane ride they shared in 1997
I found first commit in git from Dec 3, 2000
| Figure 2. Basic JUnit Test Framework Constructs |
annotations added in Java 5 permitted frameworks to move away from inheritance-based approaches — with specifically named methods (JUnit 3.8) and to leverage annotations added to classes and methods (JUnit 4)
|
|
lamda functions (JUnit 5/Jupiter) added in Java 8 permit the flexible expression of code blocks that can extend the behavior of provided functionality without requiring verbose subclassing
JUnit 4/Vintage Assertions
| JUnit 5/Jupiter Lambda Assertions
|
The success and simplicity of JUnit 4 made it hard to incorporate new features. JUnit 4 was a single module/JAR and everything that used JUnit leveraged that single jar.
The next iteration of JUnit involved a total rewrite — that separated the overall project into three (3) modules.
| Figure 4. JUnit 5 Modularization |
The name Jupiter was selected because it is the 5th planet from the Sun |
The JUnit 5 modules have several JARs within them that separate interface from implementation — ultimately decoupling the test code from the core engine.
JUnit
Mockito
Spring Boot
backwards compatibility support for legacy JUnit 4-based tests
package info.ejava.examples.app.testing.testbasics.vintage;
import lombok.extern.slf4j.Slf4j;
import org.junit.*;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@Slf4j
public class ExampleUnit4Test {
@BeforeClass
public static void setUpClass() {
log.info("setUpClass");
}
@Before
public void setUp() {
log.info("setUp");
}
@After
public void tearDown() {
log.info("tearDown");
}
@AfterClass
public static void tearDownClass() {
log.info("tearDownClass");
}
annotations come from the org.junit.*
Java package
lifecycle annotations are
@BeforeClass — a public static method run before the first @Before method call and all tests within the class
@Before - a public instance method run before each test in the class
@After - a public instance method run after each test in the class
@AfterClass - a public static method run after all tests within the class and the last @After method called
@Test(expected = IllegalArgumentException.class)
public void two_plus_two() {
log.info("2+2=4");
assertEquals(4,2+2);
throw new IllegalArgumentException("just demonstrating expected exception");
}
@Test
public void one_and_one() {
log.info("1+1=2");
assertTrue("problem with 1+1", 1+1==2);
assertEquals(String.format("problem with %d+%d",1,1), 2, 1+1);
}
@Test - a public instance method where subjects are invoked and result assertions are made
exceptions can be asserted at overall method level — but not at a specific point in the method and exception itself cannot be inspected without switching to a manual try/catch technique
asserts can be augmented with a String message in the first position
the expense of building String message is always paid whether needed or not
assertEquals(String.format("problem with %d+%d",1,1), 2, 1+1);
Vintage requires the class and methods have public access. |
16:35:42.293 INFO ...testing.testbasics.vintage.ExampleJUnit4Test - setUpClass (1)
16:35:42.297 INFO ...testing.testbasics.vintage.ExampleJUnit4Test - setUp (2)
16:35:42.297 INFO ...testing.testbasics.vintage.ExampleJUnit4Test - 2+2=4
16:35:42.297 INFO ...testing.testbasics.vintage.ExampleJUnit4Test - tearDown (2)
16:35:42.299 INFO ...testing.testbasics.vintage.ExampleJUnit4Test - setUp (2)
16:35:42.300 INFO ...testing.testbasics.vintage.ExampleJUnit4Test - 1+1=2
16:35:42.300 INFO ...testing.testbasics.vintage.ExampleJUnit4Test - tearDown (2)
16:35:42.300 INFO ...testing.testbasics.vintage.ExampleJUnit4Test - tearDownClass (1)
1 | @BeforeClass and @AfterClass called once per test class |
2 | @Before and @After executed for each @Test |
Not demonstrated — a new instance of the test class is instantiated for each test. No object state is retained from test to test without the manual use of static variables. |
JUnit Vintage provides no construct to dictate repeatable ordering of test methods within a class — thus making it hard to use test cases to depict lengthy, deterministically ordered scenarios. |
To simply change-over from Vintage to Jupiter syntax, there are a few minor changes.
annotations and assertions have changed packages from org.junit
to org.junit.jupiter.api
lifecycle annotations have changed names
assertions have changed the order of optional arguments
exceptions can now be explicitly tested and inspected within the test method body
Vintage no longer requires classes or methods to be public. Anything non-private should work. |
package info.ejava.examples.app.testing.testbasics.jupiter;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@Slf4j
class ExampleJUnit5Test {
@BeforeAll
static void setUpClass() {
log.info("setUpClass");
}
@BeforeEach
void setUp() {
log.info("setUp");
}
@AfterEach
void tearDown() {
log.info("tearDown");
}
@AfterAll
static void tearDownClass() {
log.info("tearDownClass");
}
annotations come from the org.junit.jupiter.*
Java package
lifecycle annotations are
@BeforeAll — a static method run before the first @BeforeEach method call and all tests within the class
@BeforeEach - an instance method run before each test in the class
@AfterEach - an instance method run after each test in the class
@AfterAll - a static method run after all tests within the class and the last @AfterEach method called
@Test
void two_plus_two() {
log.info("2+2=4");
assertEquals(4,2+2);
Exception ex=assertThrows(IllegalArgumentException.class, () ->{
throw new IllegalArgumentException("just demonstrating expected exception");
});
assertTrue(ex.getMessage().startsWith("just demo"));
}
@Test
void one_and_one() {
log.info("1+1=2");
assertTrue(1+1==2, "problem with 1+1");
assertEquals(2, 1+1, ()->String.format("problem with %d+%d",1,1));
}
@Test - a instance method where assertions are made
exceptions can now be explicitly tested at a specific point in the test method — permitting details of the exception to also be inspected
asserts can be augmented with a String message in the last position
this is a breaking change from Vintage syntax
the expense of building complex String messages can be deferred to a lambda function
assertEquals(2, 1+1, ()→String.format("problem with %d+%d",1,1));
16:53:44.852 INFO ...testing.testbasics.jupiter.ExampleJUnit5Test - setUpClass (1)
(3)
16:53:44.866 INFO ...testing.testbasics.jupiter.ExampleJUnit5Test - setUp (2)
16:53:44.869 INFO ...testing.testbasics.jupiter.ExampleJUnit5Test - 2+2=4
16:53:44.874 INFO ...testing.testbasics.jupiter.ExampleJUnit5Test - tearDown (2)
(3)
16:53:44.879 INFO ...testing.testbasics.jupiter.ExampleJUnit5Test - setUp (2)
16:53:44.880 INFO ...testing.testbasics.jupiter.ExampleJUnit5Test - 1+1=2
16:53:44.881 INFO ...testing.testbasics.jupiter.ExampleJUnit5Test - tearDown (2)
(3)
16:53:44.883 INFO ...testing.testbasics.jupiter.ExampleJUnit5Test - tearDownClass (1)
1 | @BeforeAll and @AfterAll called once per test class |
2 | @Before and @After executed for each @Test |
3 | The default IDE logger formatting added the new lines in between tests |
Not demonstrated — we have the default option to have a new instance per test like Vintage or same instance for all tests and a defined test method order — which allows for lengthy scenario tests to be broken into increments. See @TestInstance annotation and TestInstance.Lifecycle enum for details. |
state used by tests can be expensive to create or outside the scope of individual tests
state can be initialized and shared between test methods using one of two test instance @TestInstance
techniques
instance of the class torn down and re-instantiated between each test
must declare shared state as static
@BeforeAll
and @AfterAll
methods must be declared static
@TestInstance(TestInstance.Lifecycle.PER_METHOD) //the default (1)
class StaticShared {
private static int staticState; (2)
@BeforeAll
static void init() { (3)
log.info("state={}", staticState++);
}
@Test
void testA() { log.info("state={}", staticState); } (4)
@Test
void testB() { log.info("state={}", staticState); }
1 | test case class is instantiated per method |
2 | any shared state must be declared private |
3 | @BeforeAll and @AfterAll methods must be declared static |
4 | @Test methods are normal instance methods with access to the static state |
often during integration tests, shared state (e.g., injected components) is only available once test case is instantiated
make test case injectable by the container
@TestInstance(TestInstance.Lifecycle.PER_CLASS) (1)
class InstanceShared {
private int instanceState; (2)
@BeforeAll
void init() { (3)
log.info("state={}", instanceState++);
}
@Test
void testA() { log.info("state={}", instanceState); }
@Test
void testB() { log.info("state={}", instanceState); }
1 | one instance is created for all tests |
2 | any shared state must be declared private |
3 | @BeforeAll and @AfterAll methods must be declared non-static |
Use of @ParameterizedTest and @MethodSource (later topics), where the method source uses components injected into the test case instance is one example of when one needs to use PER_CLASS . |
Random Order
Specified Order
by Method Name
by Display Name
(custom order)
...
import org.junit.jupiter.api.*;
@TestMethodOrder(
// MethodOrderer.OrderAnnotation.class
// MethodOrderer.MethodName.class
// MethodOrderer.DisplayName.class
MethodOrderer.Random.class
)
class ExampleJUnit5Test {
@Test
@Order(1)
void two_plus_two() {
...
@Test
@Order(2)
void one_and_one() {
It is best practice to make test cases and tests within test cases modular and independent of one another.
To require a specific order violates that practice — but sometimes there are reasons to do so.
One example violation is when the overall test case is broken down into test methods that addresses a multi-step scenario.
In older versions of JUnit — that would have been required to be a single @Test calling out to helper methods. |
test case setup methods (@BeforeAll
and @BeforeEach
) and early parts of
test method (@Test
) allow us to define test context and scenario for
subject of test
assertions are added to evaluation portion of test method to determine whether subject performed correctly
result of the assertions determine the pass/fail of the test
JUnit - has built-in, basic assertions like True, False, Equals, NotEquals, etc.
Hamcrest - uses natural-language expressions for matches
AssertJ - an improvement to natural-language assertion expressions using type-based builders
The built-in JUnit assertions are functional enough to get any job done. The value in using the other libraries is their ability to express the assertion using natural-language terms without using a lot of extra, generic Java code.
basic and easy to understand — but limited in their expression
basic form of taking subject argument(s) and name of static method is the assertion made about the arguments
import static org.junit.jupiter.api.Assertions.*;
...
assertEquals(expected, lhs+rhs); (1)
1 | JUnit static method assertions express assertion of one to two arguments |
limited by the number of static assertion methods present
have to extend them by using code to manipulate the arguments (e.g., to be equal or true/false)
can easily bring in robust assertion libraries — exactly what JUnit User Guide describes to do
common pattern of taking a subject argument and a Matcher
argument
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
...
assertThat(beaver.getFirstName(), equalTo("Jerry")); (1)
1 | LHS argument is value being tested, RHS equalTo returns an object implementing Matcher interface |
Matcher
interface can be implemented by unlimited number of expressions
to implement details of assertion
uses builder pattern that starts with the subject — then offers a nested number of assertion builders that are based on the previous node type
import static org.assertj.core.api.Assertions.assertThat;
...
assertThat(beaver.getFirstName()).isEqualTo("Jerry"); (1)
1 | assertThat is a builder of assertion factories and isEqual executes an assertion in chain |
custom extensions are accomplished by creating a new builder factory at the start of the tree — small example
Assertion Generator provided to generate source code based on specific POJO classes and templates
import static info.ejava.examples.app.testing.testbasics.Assertions.*;
...
assertThat(beaver).hasFirstName("Jerry");
IDEs have an easier time suggesting assertion builders with AssertJ because everything is a method call on the previous type. IDEs have a harder time suggesting Hamcrest matchers because there is very little to base the context on. |
Assertj core library has kept up to date
assertions generator plugin has not kept up
Current default execution of plugin results in classes annotated with javax.annotation.Generated
annotation that has since been changed to jakarta
no details here, but class example in gitlab shows
downloaded source templates from the plugin source repository
edited for use with Spring Boot 3 and Jakarta-based libraries
Assertj support ticket indicates they are working on it as a part of a Java 17 upgrade |
package info.ejava.examples.app.testing.testbasics.jupiter;
import lombok.extern.slf4j.Slf4j;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@Slf4j
class AssertionsTest {
int lhs=1;
int rhs=1;
int expected=2;
@Test
void one_and_one() {
//junit 4/Vintage assertion
Assert.assertEquals(expected, lhs+rhs); (1)
//Jupiter assertion
Assertions.assertEquals(expected, lhs+rhs); (1)
//hamcrest assertion
MatcherAssert.assertThat(lhs+rhs, Matchers.is(expected)); (2)
//AssertJ assertion
org.assertj.core.api.Assertions.assertThat(lhs+rhs).isEqualTo(expected); (3)
}
}
1 | JUnit assertions are expressed using a static method and one or more subject arguments |
2 | Hamcrest asserts that the subject matches a Matcher that can be infinitely extended |
3 | AssertJ’s extensible subject assertion provides type-specific assertion builders |
report generic message of the assertion failure (location not shown) without context other than the test case and test method it was generated from (not shown)
java.lang.AssertionError: expected:<3> but was:<2> (1)
1 | we are not told what 3 and 2 are within a test except that 3 was expected and they are not equal |
additional text can sometimes help provide more context about failure
@Test
void one_and_one_description() {
//junit 4/Vintage assertion
Assert.assertEquals("math error", expected, lhs+rhs); (1)
//Jupiter assertions
Assertions.assertEquals(expected, lhs+rhs, "math error"); (2)
Assertions.assertEquals(expected, lhs+rhs,
()->String.format("math error %d+%d!=%d",lhs,rhs,expected)); (3)
//hamcrest assertion
MatcherAssert.assertThat("math error",lhs+rhs, Matchers.is(expected)); (4)
//AssertJ assertion
org.assertj.core.api.Assertions.assertThat(lhs+rhs)
.as("math error") (5)
.isEqualTo(expected);
org.assertj.core.api.Assertions.assertThat(lhs+rhs)
.as("math error %d+%d!=%d",lhs,rhs,expected) (6)
.isEqualTo(expected);
}
1 | JUnit Vintage syntax places optional message as first parameter |
2 | JUnit Jupiter moves the optional message to the last parameter |
3 | JUnit Jupiter also allows optional message to be expressed thru a lambda function |
4 | Hamcrest passes message in first position like JUnit Vintage syntax |
5 | AspectJ uses an as() builder method to supply a message |
6 | AspectJ also supports String.format and args when expressing message |
java.lang.AssertionError: math error expected:<3> but was:<2> (1)
1 | an extra "math error" was added to the reported error to help provide context |
Although AssertJ supports multiple asserts in a single call chain,
your description (as("description") ) must come before the first failing assertion. |
Because AssertJ uses chaining
|
evaluation stops at first failure in test method
there are many times when we want to know the results of several assertions
example: testing different fields in a returned object (e.g., person.getFirstName(), person.getLastName())
more complete results may give us better insight for the entire problem
JUnit Jupiter and AssertJ support testing multiple assertions prior to failing a specific test and then go on to report the results of each failed assertion
@Test
void junit_all() {
Assertions.assertAll("all assertions",
() -> Assertions.assertEquals(expected, lhs+rhs, "jupiter assertion"), (1)
() -> Assertions.assertEquals(expected, lhs+rhs,
()->String.format("jupiter format %d+%d!=%d",lhs,rhs,expected))
);
}
1 | JUnit Jupiter uses Java 8 lambda functions to execute and report results for multiple assertions |
special factory class (SoftAssertions
) to build assertions that do not immediately fail test
have chance to inspect state of assertions before failing the test
gives us chance to gather additional information to place into log
have the option not to technically fail the test
import org.assertj.core.api.SoftAssertions;
...
@Test
public void all() {
Person p = beaver; //change to eddie to cause failures
SoftAssertions softly = new SoftAssertions(); (1)
softly.assertThat(p.getFirstName()).isEqualTo("Jerry");
softly.assertThat(p.getLastName()).isEqualTo("Mathers");
softly.assertThat(p.getDob()).isAfter(wally.getDob());
log.info("error count={}", softly.errorsCollected().size()); (2)
softly.assertAll(); (3)
}
1 | a special SoftAssertions builder is used to construct assertions |
2 | we are able to inspect the status of the assertions before failure thrown |
3 | assertion failure thrown during later assertAll() call |
JUnit Jupiter and AssertJ provide direct support for inspecting Exceptions within the body of the test method
surprisingly, Hamcrest offers no built-in matchers to directly inspect Exceptions
JUnit Jupiter allows for explicit testing for Exceptions at specific points within the test method
the type of Exception is checked and made available to follow-on assertions to inspect
from that point forward JUnit assertions do not provide any direct support to inspect the Exception
import org.junit.jupiter.api.Assertions;
...
@Test
public void exceptions() {
RuntimeException ex1 = Assertions.assertThrows(RuntimeException.class, (1)
() -> {
throw new IllegalArgumentException("example exception");
});
}
1 | JUnit Jupiter provides means to assert an Exception thrown and provide it for inspection |
AssertJ has an Exception testing capability similar to JUnit Jupiter
can make explicit check for the Exception to be thrown and make Exception available for inspection
difference — AssertJ provides assertions to directly inspect Exceptions using natural-language calls
Throwable ex1 = catchThrowable( (1)
()->{ throw new IllegalArgumentException("example exception"); });
assertThat(ex1).hasMessage("example exception"); (2)
RuntimeException ex2 = catchThrowableOfType( (1)
()->{ throw new IllegalArgumentException("example exception"); },
RuntimeException.class);
assertThat(ex1).hasMessage("example exception"); (2)
1 | AssertJ provides means to assert an Exception thrown and provide it for inspection |
2 | AssertJ provides assertions to directly inspect Exceptions |
AssertJ also provides assertions that not only check that exception thrown — but can also tack on assertion builders to make on-the-spot assertions about exception thrown
same end functionality as the previous example — except:
previous method returned the exception thrown
this technique returns an assertion builder
assertThatThrownBy( (1)
() -> {
throw new IllegalArgumentException("example exception");
}).hasMessage("example exception");
assertThatExceptionOfType(RuntimeException.class).isThrownBy( (1)
() -> {
throw new IllegalArgumentException("example exception");
}).withMessage("example exception");
1 | AssertJ provides means to use the caught Exception as an assertion factory to directly inspect the Exception in a single chained call |
AssertJ has built-in support for date assertions
We have to add a separate library to gain date matchers for Hamcrest
import static org.assertj.core.api.Assertions.*;
...
@Test
public void dateTypes() {
assertThat(beaver.getDob()).isAfter(wally.getDob());
assertThat(beaver.getDob())
.as("beaver NOT younger than wally")
.isAfter(wally.getDob()); (1)
}
1 | AssertJ builds date assertions that directly inspect dates using natural-language |
external hamcrest-date
library adds natural-language date support
<!-- for hamcrest date comparisons -->
<dependency>
<groupId>org.exparity</groupId>
<artifactId>hamcrest-date</artifactId>
<version>2.0.7</version>
<scope>test</scope>
</dependency>
DateMatchers
class supplies date matchers to express date assertions
import org.exparity.hamcrest.date.DateMatchers;
import static org.hamcrest.MatcherAssert.assertThat;
...
@Test
public void dateTypes() {
//requires additional org.exparity:hamcrest-date library
assertThat(beaver.getDob(), DateMatchers.after(wally.getDob()));
assertThat("beaver NOT younger than wally", beaver.getDob(),
DateMatchers.after(wally.getDob())); (1)
}
1 | hamcrest-date adds matchers that can directly inspect dates |
more complex software to test than what we have shown so far in this lesson
components inevitably structured into layered dependencies
one layer cannot be tested without the lower layers it calls
To implement unit tests, we have a few choices:
use the real lower-level components (i.e., "all the way to the DB and back", remember — I am calling that choice "Unit Integration Tests")
create a stand-in for the lower-level components (aka "test double")
likely take first approach during integration testing
but interfacing components may bring in unrealistic dependencies for unit testing an individual component
The second approach ("test double") has a few options:
fake - using a scaled down version of the real component (e.g., in-memory SQL database)
stub - simulation of the real component by using pre-cached test data
mock - defining responses to calls and the ability to inspect the actual incoming calls made
spring-boot-starter-test
brings in a pre-integrated, mature
open source mocking framework
called Mockito
Upcoming Example
unit test with a simple Java Map<String, String>
used to demonstrate
some simulation and inspection concepts
in real unit test, the Java Map interface would stand for:
an interface we are designing (i.e., testing the interface contract we are designing from the client-side)
a test double we want to inject into a component under test that will answer with pre-configured answers and be able to inspect how called (e.g., testing collaborations within a white box test)
package info.ejava.examples.app.testing.testbasics.mockito;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class ExampleMockitoTest {
@Mock //creating a mock to configure for use in each test
private Map<String, String> mapMock;
@Captor
private ArgumentCaptor<String> stringArgCaptor;
@ExtendWith
bootstraps Mockito behavior into test case
@Mock
can be used to inject a mock of the defined type
"nice mock" is immediately available - will react in potentially useful manner by default
@Captor
can be used to capture input parameters passed to the mock calls
@InjectMocks will be demonstrated in later white box testing — where the defined mocks get injected into component under test. |
The following learning example provides a demonstration of Mock capability — using only the Mock, without the component under test. This is not something anyone would do because it is only demonstrating the Mock capability versus testing the component under test using the assembled Mock. The calls to the Mock during the "conduct test" are calls we would anticipate the component under test would make while being tested. |
@Test
public void listMap() {
//define behavior of mock during test
when(mapMock.get(stringArgCaptor.capture()))
.thenReturn("springboot", "testing"); (1)
//conduct test
int size = mapMock.size();
String secret1 = mapMock.get("happiness");
String secret2 = mapMock.get("joy");
//evaluate results
verify(mapMock).size(); //verify called once (3)
verify(mapMock, times(2)).get(anyString()); //verify called twice
//verify what was given to mock
assertThat(stringArgCaptor.getAllValues().get(0)).isEqualTo("happiness"); (2)
assertThat(stringArgCaptor.getAllValues().get(1)).isEqualTo("joy");
//verify what was returned by mock
assertThat(size).as("unexpected size").isEqualTo(0);
assertThat(secret1).as("unexpected first result").isEqualTo("springboot");
assertThat(secret2).as("unexpected second result").isEqualTo("testing");
}
1 | when()/then() define custom conditions and responses for mock within scope of test |
2 | getValue()/getAllValues() can be called on the captor to obtain value(s) passed to the mock |
3 | verify() can be called to verify what was called of the mock |
mapMock.size() returned 0 while mapMock.get() returned values.
We defined behavior for mapMock.get() but left other interface methods
in their default, "nice mock" state. |
Behavior-Driven Development (BDD) can be part of an agile development process
adds natural-language constructs to express behaviors and outcomes
BDD behavior specifications are stories with a certain structure that contain an acceptance criteria that follows a "given", "when", "then" structure:
given - initial context
when - event triggering scenario under test
then - expected outcome
strong push to express acceptance criteria in code that can be executed versus a document
far from a perfect solution, JUnit, AssertJ, and Mockito do provide some syntax support for BDD-based testing:
JUnit Jupiter allows the assignment of meaningful natural-language phrases for test case and test method names. Nested classes can also be employed to provide additional expression.
Mockito defines alternate method names to better map to the given/when/then language of BDD
AssertJ defines alternate assertion factory names using then()
and and.then()
wording
import org.junit.jupiter.api.*;
import static org.assertj.core.api.BDDAssertions.and;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
@ExtendWith(MockitoExtension.class)
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) (1)
@DisplayName("map") (2)
public class ExampleMockitoTest {
...
@Nested (3)
public class when_has_key { (1)
@Test
public void returns_values() {
//given
given(mapMock.get(stringArgCaptor.capture()))
.willReturn("springboot", "testing"); (4)
//alt syntax
// doReturn("springboot", "testing")
// .when(mapMock).get(stringArgCaptor.capture());
//when
int size = mapMock.size();
String secret1 = mapMock.get("happiness");
String secret2 = mapMock.get("joy");
//then - can use static import for BDDMockito or BDDAssertions, not both
then(mapMock).should().size(); //verify called once (5)
then(mapMock).should(times(2)).get(anyString()); //verify called twice
(7) (6)
and.then(stringArgCaptor.getAllValues().get(0)).isEqualTo("happiness");
and.then(stringArgCaptor.getAllValues().get(1)).isEqualTo("joy");
and.then(size).as("unexpected size").isEqualTo(0);
and.then(secret1).as("unexpected first result").isEqualTo("springboot");
and.then(secret2).as("unexpected second result").isEqualTo("testing");
}
}
1 | JUnit DisplayNameGenorator.ReplaceUnderscores will form a natural-language display name by replacing
underscores with spaces |
2 | JUnit DisplayName sets the display name to a specific value |
3 | JUnit Nested classes can be used to better express test context |
4 | Mockito when/then syntax replaced by given/will syntax expresses the definition of the mock |
5 | Mockito verify/then syntax replaced by then/should syntax expresses assertions made on the mock |
6 | AssertJ then syntax expresses assertions made to supported object types |
7 | AssertJ and field provides a natural-language way to access both AssertJ then and
Mockito then in the same class/method |
AssertJ provides a static final and field to allow its static then()
and Mockito’s static then() to be accesses in the same class/test |
We can define a global setting for the display name generator using junit-platform.properties
junit.jupiter.displayname.generator.default = \
org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores
This can also be used to express:
method order
class order
test instance lifecycle
@Parameterized test naming
parallel execution
TipCalculator - returns the amount of tip required when given a certain bill total and rating of service. We could have multiple evaluators for tips and have defined an interface for clients to depend upon.
BillCalculator - provides the ability to calculate the share of an equally split bill given a total, service quality, and number of people.
review the simple test constructs in terms of the Tipping example
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) (1)
@DisplayName("Standard Tipping Calculator")
public class StandardTippingCalculatorImplTest {
//subject under test
private TipCalculator tipCalculator; (2)
@BeforeEach (3)
void setup() { //simulating a complex initialization
tipCalculator=new StandardTippingImpl();
}
1 | DisplayName is part of BDD naming and optional for all tests |
2 | there will be one or more objects under test. These will be POJOs. |
3 | @BeforeEach plays the role of a the container — wiring up objects under test |
expressed in terms of BDD conventions
broken up into "given", "when", and "then" blocks
highlighted with use of BDD syntax where provided (JUnit and AssertJ)
@Test
public void given_fair_service() { (1)
//given - a $100 bill with FAIR service (2)
BigDecimal billTotal = new BigDecimal(100);
ServiceQuality serviceQuality = ServiceQuality.FAIR;
//when - calculating tip (2)
BigDecimal resultTip = tipCalculator.calcTip(billTotal, serviceQuality);
//then - expect a result that is 15% of the $100 total (2)
BigDecimal expectedTip = billTotal.multiply(BigDecimal.valueOf(0.15));
then(resultTip).isEqualTo(expectedTip); (3)
}
1 | using JUnit snake_case natural language expression for test name |
2 | BDD convention of given, when, then blocks. Helps to be short and focused |
3 | using AssertJ assertions with BDD syntax |
subject under test has a required dependency
pure unit test would mock out all dependencies (TipCalculator
)
@ExtendWith(MockitoExtension.class) (1)
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@DisplayName("Bill CalculatorImpl Mocked Unit Test")
public class BillCalculatorMockedTest {
//subject under test
private BillCalculator billCalculator;
@Mock (2)
private TipCalculator tipCalculatorMock;
@BeforeEach
void init() { (3)
billCalculator = new BillCalculatorImpl(tipCalculatorMock);
}
1 | Add Mockito extension to JUnit |
2 | Identify which interfaces to Mock |
3 | In this example, we are manually wiring up the subject under test |
TipCalculator mock instructed on what to return based on input criteria
mock making call activity available to the test
@Test
public void calc_shares_for_people_including_tip() {
//given - we have a bill for 4 people and tip calculator that returns tip amount
BigDecimal billTotal = new BigDecimal(100.0);
ServiceQuality service = ServiceQuality.GOOD;
BigDecimal tip = billTotal.multiply(new BigDecimal(0.18));
int numPeople = 4;
//configure mock
given(tipCalculatorMock.calcTip(billTotal, service)).willReturn(tip); (1)
//when - call method under test
BigDecimal shareResult = billCalculator.calcShares(billTotal, service, numPeople);
//then - tip calculator should be called once to get result
then(tipCalculatorMock).should(times(1)).calcTip(billTotal,service); (2)
//verify correct result
BigDecimal expectedShare = billTotal.add(tip).divide(new BigDecimal(numPeople));
and.then(shareResult).isEqualTo(expectedShare);
}
1 | configuring response behavior of Mock |
2 | optionally inspecting subject calls made |
leverage Mockito to instantiate our subject(s) under test and inject them with mocks
takes over at least one job the @BeforeEach
was performing
@ExtendWith(MockitoExtension.class)
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@DisplayName("Bill CalculatorImpl")
public class BillCalculatorImplTest {
@Mock
TipCalculator tipCalculatorMock;
/*
Mockito is instantiating this implementation class for us an injecting Mocks
*/
@InjectMocks (1)
BillCalculatorImpl billCalculator;
1 | instantiates and injects out subject under test |
pure unit testing can be efficiently executed without a Spring context
there will eventually be a time to either:
integrate peer components with one another (horizontal integration)
integrate layered components to test the stack (vertical integration)
not easily accomplished without a Spring context
whatever created outside of Spring context will be different from production
Spring Boot and Spring context can be brought into the test picture to integrate components and component infrastructure present in the end application
it will come at a performance cost
potentially add external resource dependencies
— don’t look for it to replace lightweight pure unit testing alternatives
There are two primary things that will change with our Spring Boot integration test:
define a Spring context for our test to operate using @SpringBootTest
inject components we wish to use/test from the Spring context into our tests
using @Autowire
I found the following article: Integration Tests with @SpringBootTest, by Tom Hombergs and his "Testing with Spring Boot" series to be quite helpful in clarifying my thoughts and originally preparing these lecture notes. The Spring Boot Testing reference web page provides detailed coverage of the test constructs that go well beyond what I am covering at this point in the course. We will pick up more of that material as we get into web and data tier topics. |
annotate test with @SpringBootTest
instantiates default Spring context based on configuration defined or found
package info.ejava.examples.app.testing.testbasics.tips;
...
import org.springframework.boot.test.context.SpringBootTest;
...
@SpringBootTest (1)
public class BillCalculatorNTest {
1 | using the default configuration search rules |
looks for a class annotated with @SpringBootConfiguration
at or above Java package containing test
we have a class in a parent directory that represents our @SpringBootApplication
@SpringBootApplication
wraps @SpringBootConfiguration
that class will be used to define the Spring context for test
package info.ejava.examples.app.testing.testbasics;
...
@SpringBootApplication
// wraps => @SpringBootConfiguration
public class TestBasicsApp {
public static void main(String...args) {
SpringApplication.run(TestBasicsApp.class,args);
}
}
When using the @SpringBootApplication, all components normally a part of the application will be part of the test. Be sure to define auto-configuration exclusions for any production components that would need to be turned off during testing.
|
can make explicit reference to class to use
if not in a standard relative directory
we wanted to use a custom version of application for testing
import info.ejava.examples.app.testing.testbasics.TestBasicsApp;
...
@SpringBootTest(classes = TestBasicsApp.class)
public class BillCalculatorNTest {
Assuming the components required for test is known and a manageable number…
@Component
@RequiredArgsConstructor
public class BillCalculatorImpl implements BillCalculator {
private final TipCalculator tipCalculator;
...
@Component
public class StandardTippingImpl implements TipCalculator {
...
We can explicitly reference component classes needed to be in the Spring context.
@SpringBootTest(classes = {BillCalculatorImpl.class, StandardTippingImpl.class})
public class BillCalculatorNTest {
@Autowired
BillCalculator billCalculator;
prior to adding the Spring context, Spring Boot configuration and logging conventions were not being enacted
can now designate special profiles activated to define test environment
package info.ejava.examples.app.testing.testbasics.tips;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@ActiveProfiles("test") (1)
public class BillCalculatorNTest {
1 | activating the "test" profile for this test |
# application-test.properties (1)
logging.level.info.ejava.examples.app.testing.testbasics=DEBUG
1 | "test" profile setting loggers for package under test to DEBUG severity threshold |
Putting the pieces together, we have
a complete Spring context
BillCalculator
injected into the test from the Spring context
TipCalculator
injected into billCalculator instance from Spring context
a BDD natural-language, unit integration test that verifies result of bill calculator and tip calculator working together
@SpringBootTest
@ActiveProfiles("test")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@DisplayName("bill calculator")
public class BillCalculatorNTest {
@Autowired
BillCalculator billCalculator;
@Test
public void calc_shares_for_bill_total() {
//given
BigDecimal billTotal = BigDecimal.valueOf(100.0);
ServiceQuality service = ServiceQuality.GOOD;
BigDecimal tip = billTotal.multiply(BigDecimal.valueOf(0.18));
int numPeople = 4;
//when - call method under test
BigDecimal shareResult=billCalculator.calcShares(billTotal,service,numPeople);
//then - verify correct result
BigDecimal expectedShare = billTotal.add(tip).divide(BigDecimal.valueOf(4));
then(shareResult).isEqualTo(expectedShare);
}
}
When we run our test we get the following console information printed. Note that
the DEBUG
messages are from the BillCalculatorImpl
DEBUG
is being printed because the "test" profile is active and the "test" profile
set the severity threshold for that package to be DEBUG
method and line number information is also displayed because the test profile defines an expressive log event pattern
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.3.2)
14:17:15.427 INFO BillCalculatorNTest#logStarting:55 - Starting BillCalculatorNTest
14:17:15.429 DEBUG BillCalculatorNTest#logStarting:56 - Running with Spring Boot v2.2.6.RELEASE, Spring v5.2.5.RELEASE
14:17:15.430 INFO BillCalculatorNTest#logStartupProfileInfo:655 - The following profiles are active: test
14:17:16.135 INFO BillCalculatorNTest#logStarted:61 - Started BillCalculatorNTest in 6.155 seconds (JVM running for 8.085)
14:17:16.138 DEBUG BillCalculatorImpl#calcShares:24 - tip=$9.00, for $50.00 and GOOD service
14:17:16.142 DEBUG BillCalculatorImpl#calcShares:33 - share=$14.75 for $50.00, 4 people and GOOD service
14:17:16.143 INFO BillHandler#run:24 - bill total $50.00, share=$14.75 for 4 people, after adding tip for GOOD service
14:17:16.679 DEBUG BillCalculatorImpl#calcShares:24 - tip=$18.00, for $100.00 and GOOD service
14:17:16.679 DEBUG BillCalculatorImpl#calcShares:33 - share=$29.50 for $100.00, 4 people and GOOD service
@SpringBootTest
annotation is a general purpose test annotation that likely will
work in many generic cases
other cases may need a specific database or other technologies available
Spring Boot pre-defines a set of Test Slices that can establish more specialized test environments
@DataJpaTest - JPA/RDBMS testing for the data tier
@DataMongoTest - MongoDB testing for the data tier
@JsonTest - JSON data validation for marshalled data
@RestClientTest - executing tests that perform actual HTTP calls for the web tier
We will revisit these topics as we move through the course and construct tests relative additional domains and technologies.
previous @SpringBootTest
example instantiated complete Spring context
to inject and execute test(s) against set of real components
we may need the Spring context — but do not need/want other components
this example will mock out TipCalculator
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.assertj.core.api.BDDAssertions.and;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.times;
@SpringBootTest(classes={BillCalculatorImpl.class})//defines custom Spring context (1)
@ActiveProfiles("test")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@DisplayName("Bill CalculatorImpl Mocked Integration")
public class BillCalculatorMockedNTest {
@Autowired //subject under test (2)
private BillCalculator billCalculator;
@MockBean //will satisfy Autowired injection point within BillCalculatorImpl (3)
private TipCalculator tipCalculatorMock;
1 | defining a custom context that excludes TipCalculator component(s) |
2 | injecting BillCalculator bean under test from Spring context |
3 | defining a mock to be injected into BillCalculatorImpl in Spring context |
actual test similar to earlier example when real TipCalculator
injected
we must define mock’s behavior and optionally determine if it was called
@Test
public void calc_shares_for_people_including_tip() {
//given - we have a bill for 4 people and tip calculator that returns tip amount
BigDecimal billTotal = BigDecimal.valueOf(100.0);
ServiceQuality service = ServiceQuality.GOOD;
BigDecimal tip = billTotal.multiply(BigDecimal.valueOf(0.18));
int numPeople = 4;
//configure mock
given(tipCalculatorMock.calcTip(billTotal, service)).willReturn(tip); (1)
//when - call method under test (2)
BigDecimal shareResult = billCalculator.calcShares(billTotal, service, numPeople);
//then - tip calculator should be called once to get result
then(tipCalculatorMock).should(times(1)).calcTip(billTotal,service); (3)
//verify correct result
BigDecimal expectedShare = billTotal.add(tip).divide(BigDecimal.valueOf(numPeople));
and.then(shareResult).isEqualTo(expectedShare); (4)
}
1 | instruct the Mockito mock to return a tip result |
2 | call method on subject under test |
3 | verify mock was invoked N times with the value of the bill and service |
4 | verify with AssertJ that the resulting share value was the expected share value |
production artifacts that are part of deployed artifact — placed in
src/main
(java
and resources
)
test artifacts are placed in src/test
(java
and resources
)
|-- pom.xml
`-- src
`-- test
|-- java
| `-- info
| `-- ejava
| `-- examples
| `-- app
| `-- testing
| `-- testbasics
| |-- PeopleFactory.java
| |-- jupiter
| | |-- AspectJAssertionsTest.java
| | |-- AssertionsTest.java
| | |-- ExampleJUnit5Test.java
| | `-- HamcrestAssertionsTest.java
| |-- mockito
| | `-- ExampleMockitoTest.java
| |-- tips
| | |-- BillCalculatorContractTest.java
| | |-- BillCalculatorImplTest.java
| | |-- BillCalculatorMockedNTest.java
| | |-- BillCalculatorNTest.java
| | `-- StandardTippingCalculatorImplTest.java
| `-- vintage
| `-- ExampleJUnit4Test.java
`-- resources
|-- application-test.properties
Maven Surefire plugin looks for classes compiled from src/test/java
tree having
prefix of "Test" or suffix of "Test", "Tests", or "TestCase" by default
Surefire starts up the JUnit context(s) and provides test results to the console and target/surefire-reports directory
Surefire is part of the standard "jar" profile we use for normal Java projects and will run automatically
$ mvn clean test
...
[INFO] Results:
[INFO]
[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 14.280 s
JUnit Jupiter @Tag
annotations can categorize tests
"springboot" tag was added to all tests that launch the Spring context
"tips" tag was added to all tests that are part of the tips example
import org.junit.jupiter.api.*;
...
@SpringBootTest(classes = {BillCalculatorImpl.class}) //defining custom Spring context
@Tag("springboot") @Tag("tips") (1)
...
public class BillCalculatorMockedNTest {
1 | test case has been tagged with JUnit "springboot" and "tips" tag values |
can use tag names in "groups" property specification to Maven Surefire
only run matching tests
example: all tests tagged with "tips" but not tagged with "springboot"
fewer tests executed and a much faster completion time
$ mvn clean test -Dgroups='tips & !springboot' -Pbdd (1) (2)
...
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running Bill Calculator Contract
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.41 s - in Bill Calculator Contract
[INFO] Running Bill CalculatorImpl
15:43:47.605 [main] DEBUG info.ejava.examples.app.testing.testbasics.tips.BillCalculatorImpl - tip=$50.00, for $100.00 and GOOD service
15:43:47.608 [main] DEBUG info.ejava.examples.app.testing.testbasics.tips.BillCalculatorImpl - share=$37.50 for $100.00, 4 people and GOOD service
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.165 s - in Bill CalculatorImpl
[INFO] Running Standard Tipping Calculator
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.004 s - in Standard Tipping Calculator
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.537 s
1 | execute tests with tag "tips" and without tag "springboot" |
2 | activating "bdd" profile that configures Surefire reports within the example Maven environment setup to understand display names |
Maven Failsafe plugin looks for classes compiled from src/test/java
tree that having
prefix of "IT" or suffix of "IT", or "ITCase" by default
Failsafe is part of the standard Maven "jar" profile
runs later in the build process
unlike Surefire that runs within one
Maven phase (test
) — Failsafe runs within the scope of four Maven phases:
pre-integration-test - when external resources get started (e.g., web server)
integration-test - when tests are executed
post-integration-test - when external resources are stopped/cleaned up (e.g., shutdown web server)
verify - when results of tests are evaluated and build potentially fails
external processes are normally started and stopped using Maven plugins
multiple phases are required for IT tests so that:
all resources are ready to test once the tests begin
all resources can be shutdown prior to failing the build for a failed test
With the robust capability to stand up a Spring context within a single JVM, we really have limited use for Failsafe for testing Spring Boot applications
exception for when we truly need to interface with something external — like stand up a real database or host endpoints in Docker images
Maven "integration tests" come with extra hooks and overhead that may not be technically needed for integration tests that can be executed within a single JVM
Tests often require additional components that are not part of the Spring context under test — or need to override one or more of those components.
SpringBoot supplies a @TestConfiguration
annotation that:
allows the class to be skipped by standard component scan
is loaded into a @SpringBootTest
to add or replace components
TipCalculator
component located using component scan
has name "standardTippingImpl" if we do not supply override within @Component
annotation.
@Primary (1)
@Component
public class StandardTippingImpl implements TipCalculator {
1 | declaring type as primary to make example more significant |
bean injected into BillCalculatorImpl.tipCalculator
because it implements required type
@Component
@RequiredArgsConstructor
public class BillCalculatorImpl implements BillCalculator {
private final TipCalculator tipCalculator;
Our intent here is to manually write a stub and have it replace the TipCalculator
from the application’s Spring context.
import org.springframework.boot.test.context.TestConfiguration;
...
@TestConfiguration(proxyBeanMethods = false) //skipped in component scan -- manually included (1)
public class MyTestConfiguration {
@Bean
public TipCalculator standardTippingImpl() { (2)
return new TipCalculator() {
@Override
public BigDecimal calcTip(BigDecimal amount, ServiceQuality serviceQuality) {
return BigDecimal.ZERO; (3)
}
};
}
}
1 | @TestConfiguration annotation prevents class from being picked up in normal component scan |
2 | standardTippingImpl name matches existing component |
3 | test-specific custom response |
Since we are going to replace an existing component, we need to enable bean overrides using the following property definition.
@SpringBootTest(
properties = "spring.main.allow-bean-definition-overriding=true"
)
public class TestConfigurationNTest {
Otherwise, we end up with the following error when we make our follow-on changes.
***************************
APPLICATION FAILED TO START
***************************
Description:
The bean 'standardTippingImpl', defined in class path resource
[.../testconfiguration/MyTestConfiguration.class], could not be registered.
A bean with that name has already been defined in file
[.../tips/StandardTippingImpl.class] and overriding is disabled.
Action:
Consider renaming one of the beans or enabling overriding by setting
spring.main.allow-bean-definition-overriding=true
We can have the @TestConfiguration
class automatically found using an embedded static class.
@SpringBootTest(properties={"..."})
public class TestConfigurationNTest {
@Autowired
BillCalculator billCalculator; (1)
@TestConfiguration(proxyBeanMethods = false)
static class MyEmbeddedTestConfiguration { (2)
@Bean
public TipCalculator standardTippingImpl() { ... }
}
1 | injected billCaculator will be injected with @Bean from @TestConfiguration |
2 | embedded static class used automatically |
Alternatively, we can place the configuration in a separate/stand-alone class.
@TestConfiguration(proxyBeanMethods = false)
public class MyTestConfiguration {
@Bean
public TipCalculator tipCalculator() {
return new TipCalculator() {
@Override
public BigDecimal calcTip(BigDecimal amount, ServiceQuality serviceQuality) {
return BigDecimal.ZERO;
}
};
}
}
The external @TestConfiguration
will only be used if specifically named in either:
@SpringBootTest.classes
@ContextConfiguration.classes
@Import.value
Pick one way.
@SpringBootTest(
classes=MyTestConfiguration.class, //way1 (1)
properties = "spring.main.allow-bean-definition-overriding=true"
)
@ContextConfiguration(classes=MyTestConfiguration.class) //way2 (2)
@Import(MyTestConfiguration.class) //way3 (3)
public class TestConfigurationNTest {
1 | way1 leverages the `@SpringBootTest configuration |
2 | way2 pre-dates @SpringBootTest |
3 | way3 pre-dates @SpringBootTest and is a standard way to import a configuration definition from one class to another |
Running the following test results in:
a single TipCalculator
registered in the list because each considered have the same name and overriding is enabled
the TipCalculator
used is one of the @TestConfiguration
-supplied components
@SpringBootTest(
classes=MyTestConfiguration.class,
properties = "spring.main.allow-bean-definition-overriding=true")
public class TestConfigurationNTest {
@Autowired
BillCalculator billCalculator;
@Autowired
List<TipCalculator> tipCalculators;
@Test
void calc_has_been_replaced() {
//then
then(tipCalculators).as("too many topCalculators").hasSize(1);
then(tipCalculators.get(0).getClass()).hasAnnotation(TestConfiguration.class); (1)
}
1 | @Primary TipCalculator bean replaced by our @TestConfiguration -supplied bean |
In this module we:
learned the importance of testing
introduced some of the testing capabilities of libraries integrated
into spring-boot-starter-test
went thru an overview of JUnit Vintage and Jupiter test constructs
stressed the significance of using assertions in testing and the value in making them based on natural-language to make them easy to understand
introduced how to inject a mock into a subject under test
demonstrated how to define a mock for testing a particular scenario
demonstrated how to inspect calls made to the mock during testing of a subject
discovered how to switch default Mockito and AssertJ methods to match Business-Driven Development (BDD) acceptance test keywords
implemented unit integration tests with Spring context using @SpringBootTest
implemented mocks into the Spring context of a unit integration test
ran tests using Maven Surefire
implemented a @TestConfiguration
with component override