Writing Tests
Estimated time to read: 15 minutes
Test Structure¶
Scenario Folder Structure¶
- Tests
- gradle
- src
- main
- java
- io
- entityfour
- product
- Product.java
- reward
- RewardByConversionService.java
- RewardByDiscountService.java
- RewardByGiftService.java
- RewardInformation.java
- RewardService.java
- resources
- test
- java
- io
- entityfour
- RewardsByDiscountService.java
- build.gradle
- pom.xml
- settings.gradle
Product package¶
The product
package contains a class which represents a product.
It has three fields, the ID, name and price of the product, as well as a constructor, x-ers and two methods, equals
and hashCode
.
package io.entityfour.Product;
public class Product {
private long id;
private String name;
private double price;
public Product(long id, String name, double price) {
this.id = id;
this.name = name;
this.price = price;
}
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getPrice(){
return price;
}
public void setPrice(double price) {
this.price = price;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Product product = (Product) o;
return id == product.id;
}
@Override
public int hashCode() {
return (int) (id ^ (id >>> 32));
}
}
Reward package¶
The reward package is split up into multiple components.
RewardService¶
The RewardService
abstract class is the base class for the three types of rewards. All of them will have a field to store the points needed for the reward, the method that will decide if the reward is given, and a helped method that calculates the total of the order given a list of products.
package io.entityfour.reward;
public abstract class RewardService {
protected long neededPoints; // Store points needed for the reward
public abstract RewardInformation applyReward (List<Product> order, long customerPoints); // The method that decides if a reward is given
protected double calculateTotal(List<Product> order) {
double sum = 0;
if (order != null) {
sum = order.stream().mapToDouble(Product::getPrice).sum();
}
return sum;
} // Helper method that calculates the total of the order, given a list of products.
public long getNeededPoints() {
return neededPoints;
}
public void setNeededPoints(long neededPoints) {
this.neededPoints = neededPoints;
}
}
RewardInformation¶
The method applyReward
in the code above returns an object of type RewardInformation
updates the points that were redeemed and the discount given.
package io.entityfour.reward;
public class RewardInformation {
private long pointsRedeemed;
private double discount;
public RewardInformation() {
}
public RewardInformation(long pointsRedeemed, double discount) {
this.pointsRedeemed = pointsRedeemed;
this.discount = discount;
}
public long getPointsRedeemed() {
return pointsRedeemed;
}
public void setPointsRedeemed(long pointsRedeemed) {
this.pointsRedeemed = pointsRedeemed;
}
public double getDiscount() {
return discount;
}
public void setDiscount(double discount) {
this.discount = discount;
}
}
RewardByConversionService¶
If the customer points are greater than the points needed for the reward and the total of the order is greater than the reward amount, it fills a RewardInformation
object and returns it.
If these conditions are not met, we just return a RewardInformation
object with the default zero values for the points and the discount.
```java title="RewardByConversionSerivce.java"
public class RewardByConversionSerivce extends RewardService { private double amount;
@Override public RewardInformation applyReward(List
if(customerPoints >= neededPoints) { double orderTotal = calculateTotal(order); if(orderTotal > amount) { rewardInformation = new RewardInformation(neededPoints, amount) } }
return rewardInformation; }
public double getAmount() { return amount; }
public void setAmount(double amount) { this.amount = amount; } }
#### RewardByDiscountService
If the customer points are greater than the points needed for the reward, the discount is calculated to fill the `RewardInformation` object.
```java title="RewardByDiscountService.java"
package io.entityfour.reward;
public class RewardByDiscountService extends RewardService {
private double amount;
@Override
public RewardInformation applyReward(List<Product> order, long customerPoints) {
RewardInformation rewardInformation = new RewardInformation();
if(customerPoints >= neededPoints) {
double orderTotal = calculateTotal(order);
if(orderTotal > amount) {
rewardInformation = new RewardInformation(neededPoints, amount)
}
}
return rewardInformation;
}
public double getPercentage() {
return percentage;
}
public void setPercentage(double percentage) {
if(percentage > 0) {
this.percentage = percentage
} else {
...
}
}
}
RewardByGiftService¶
If the customer points are greater than the points needed for the reward, it checks if the gift product is in the order to fill a RewardInformation object with the product's price as the discount.
package io.entityfour.reward;
public class RewardByGiftService extends RewardService {
private long giftProductId;
@Override
public RewardInformation applyReward(List<Product> order, long customerPoints) {
RewardInformation rewardInformation = new RewardInformation();
if(customerPoints >= neededPoints) {
Optional<Product> result = order.stream().filter(p -> p.getId() == giftProductId).findAny();
if(result.isPresent()) {
rewardInformation = new RewardInformation(neededPoints, result.get().getPrice());
}
}
}
public long getGiftProductId() {
return giftProductId;
}
public void setGiftProductId(long giftProductId) {
this.giftProductId = giftProductId
}
Tests¶
Test Folder Structure¶
The test directory within the project folder contains java files and packages that are used to test different elements of the application code.
The folder structure for this directory typically mirrors the structure of the source folder, this is the convention expected by Gradle, Maven and the JUnit Console Launcher by default.
Test Classes¶
Each method of a test class must test exactly one thing.
Methods within a test class can be named however. Though it is good practise to include the name of the method that you are testing.
Note
When declaring a test function, it must be annotated correctly by the use of the @Test
annotation so that JUnit can execute it.
The example is a test to see if the setter method setNeededPoints
within the class RewardByDiscountService
is valid.
package io.entityfour.reward;
import ...
class RewardByDiscountServiceTest {
@Test
void setNeededPoints() {
RewardByDiscountService reward = new RewardByDiscountService(); //Create an instance of the RewardByDiscountService class.
reward.setNeededPoints(100); //Use the setter within the above instance to set the value of neededPoints to 100
assertEquals(100, reward.getNeededPoints()); // Assert that getNeededPoints returns 100.
// assertEquals is a static method from the Assertions class. It is a helper method that allows you to check if the behaviour that you expect from the class is correct or not.
}
@Test
void setPercentageForPoints() {
RewardByDiscountService reward = new RewardByDiscountService();
reward.setPercentage(0.1);
assertEquals(0.1, reward.getPercentage());
}
@Test
void zeroCustomerPoints() {
RewardByDiscountService reward = new RewardByDiscountService();
reward.setPercentage(0.1);
reward.setNeededPoints(0.1);
Product smallDecaf = new Product(1,"Small Decaf",1.99);
List<Product> order = Collection.singletonList(smallDecaf);
assertEquals(0, info.getDiscount());
assertEquals(0, info.getPointsRedeemed());
}
}
Test Order¶
JUnit doesn't run test methods in a pre-defined order by default. Tests should not depend on the order they are executed. However, the @TestMethodOrder(MethordOrderer.OrderAnnotation.class)
annotation can be used to explicitly imply the execution order by numerical value or @TestMethodOrder(MethordOrderer.MethodName.class)
can be used to explicitly imply the execution order by method name.
Test Output¶
Success
If tests succeeded, JUnit will return an exit code of 0.
Failure
All the test methods will be executed, regardless if one or more fail.
JUnit will report how many tests failed and also why they failed with an AssertionFailed
exception, thrown by the assertion method.
JUnit also includes the expected value from the assertion in the test method, the actual value that was returned from the application code and a stack trace of the error.
Lifecycle Methods¶
Each test method, generally, has four phases:
- Arrange / Test Fixture
- Set up the object required for the test
- Act
- Call the appropriate function to action a test against the object
- Assert
- Check the result of the action and compare its expected value against the actual value
- Annihilation
- Return the system back into its pre-test state. This is typically done implicitly by the JVM Garbage Collection service.## Test Hierarchies
Following the above lifecycle of a test method, can help to avoid implementing too much functionality within one test.
Arrange / Test Fixture¶
The test fixture phase contains everything required to execute the test. This includes, but is not limited to creating objects and setting properties.
There are three different approaches for managing Test Fixtures:
- Transient Fresh - A new fixture is set up each time a test is run
- Persistent Fresh - These fixtures survive between tests. However, it is initialised before each test runs
- Persistent Shared - These fixtures also survive between tests. However, it is not initialised before each test and allows states to accumulate from test to test.
Analogy¶
Imagine you need to write some notes on a piece of paper.
Using a transient fresh approach, you would use a new sheet of paper for every note you need to write
Using a persistent fresh approach, you would use just one sheet of paper and erase the previous note to write a new one each time.
Using a persistent shared approach, you would use just one sheet of paper without erasing them.
Lifecycle Annotations¶
JUnit includes annotations which can be used to specify execution of a method during the lifecycle of a test..
Once per method | Description | Once per class | Description | |
---|---|---|---|---|
@BeforeEach | Executes a method before the execution of each test | @BeforeAll | Executes a method before all tests are executed | |
@AfterEach | Executes a method after the execution of each test | @AfterAll | Executes a method after all test are executed |
Lifecycle Execution¶
By default, JUnit creates an instance of each test class before executing each test method to run them in isolation and to avoid unexpected side-effects due to the instance state.
This behaviour can be overridden to execute all of the test methods on the same instance. This can be controlled by annotation a test class with @TestInstance()
and setting instance lifecycle to either PER_METHOD
or PER_CLASS
...
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
or by starting the JVM with the following arguments...
-Djunit.jupiter.testinstance.lifecycle.default=per_method
-Djunit.jupiter.testinstance.lifecycle.default=per_class
or within a file named junit-platform.properties...
junit.jupiter.testinstance.lifecycle.default=per_method
junit.jupiter.testinstance.lifecycle.default=per_class
Lifecycle Implementation¶
In the examples code below, each has had its TestInstance()
lifecycle value set explicitly.
The following have been added to the example from earlier:
- A Constructor
- Methods annotated with @BeforeAll, @BeforeEach, @AfterAll and @AfterEach
setUp
andtearDown
are popular naming conventions for before and after methods respectively
Code Example¶
package io.entityfour.reward;
import ...
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
class RewardByDiscountServiceTest {
RewardByDiscountServiceTest() {
System.out.println("Constructor");
}
@BeforeAll //Note that @BeforeAll and @AfterAll are static when using a PER_METHOD lifecycle, as these are called before the instance of the test class is created.
static void setUpAll() {
System.out.println("BeforeAll");
}
@BeforeEach
void setUp() {
System.out.println("BeforeEach");
}
@AfterAll //Note that @BeforeAll and @AfterAll are static when using a PER_METHOD lifecycle, as these are called before the instance of the test class is created.
static void tearDownAll() {
System.out.println("AfterAll")
}
@AfterEach
void tearDown() {
System.out.println("AfterEach")
}
@Test
void setNeededPoints() {
RewardByDiscountService reward = new RewardByDiscountService();
reward.setNeededPoints(100);
assertEquals(100, reward.getNeededPoints());
}
@Test
void setPercentageForPoints() {
RewardByDiscountService reward = new RewardByDiscountService();
reward.setPercentage(0.1);
assertEquals(0.1, reward.getPercentage());
}
@Test
void zeroCustomerPoints() {
RewardByDiscountService reward = new RewardByDiscountService();
reward.setPercentage(0.1);
reward.setNeededPoints(0.1);
Product smallDecaf = new Product(1,"Small Decaf",1.99);
List<Product> order = Collection.singletonList(smallDecaf);
assertEquals(0, info.getDiscount());
assertEquals(0, info.getPointsRedeemed());
}
}
package io.entityfour.reward;
import ...
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class RewardByDiscountServiceTest {
RewardByDiscountServiceTest() {
System.out.println("Constructor");
}
//Since the same instance is used for all the test methods and the and the instanced is created before calling the BeforeAll method, when using the PER_CLASS lifecycle, the Beforeall and AfterAll methods are not required to be static.
@BeforeAll
static void setUpAll() {
System.out.println("BeforeAll");
}
@BeforeEach
void setUp() {
RewardByDiscountService reward = new RewardByDiscountService();
System.out.println("BeforeEach");
}
@AfterAll
static void tearDownAll() {
System.out.println("AfterAll")
}
@AfterEach
void tearDown() {
System.out.println("AfterEach")
}
@Test
void setNeededPoints() {
reward.setNeededPoints(100);
assertEquals(100, reward.getNeededPoints());
}
@Test
void setPercentageForPoints() {
reward.setPercentage(0.1);
assertEquals(0.1, reward.getPercentage());
}
@Test
void zeroCustomerPoints() {
reward.setPercentage(0.1);
reward.setNeededPoints(0.1);
Product smallDecaf = new Product(1,"Small Decaf",1.99);
List<Product> order = Collection.singletonList(smallDecaf);
assertEquals(0, info.getDiscount());
assertEquals(0, info.getPointsRedeemed());
}
}
Output¶
BeforeAll // The BeforeAll method runs before everything
Constructor // The first test initialises a new object using a constructor
BeforeEach // The BeforeEach method runs before each test
Test setPercentageForPoints // The first tests is called
AfterEach // The AfterEach method runs after each test
Constructor // The second test initialises a new object using a constructor
BeforeEach // ...
Test zeroCustomerPoints
AfterEach
Constructor
BeforeEach
Test setNeededPoints
AfterEach
AfterAll // The AfterAll method is called after all tests have been run.
Constructor // The constructor is initialised first, which is then shared between each test.
BeforeAll // The BeforeAll method runs before tests are performed
BeforeEach // The BeforeEach method runs before each test
Test setPercentageForPoints // The first tests is called
AfterEach // The AfterEach method runs after each test
BeforeEach // The BeforeEach method runs before each test
Test zeroCustomerPoints // ...
AfterEach
BeforeEach
Test setNeededPoints
AfterEach
AfterAll // The AfterAll method is called after all tests have been run.
Test Hierarchies¶
When setting values within a test class, there are three possible places that these values can be placed for them to be parsed.
- Within each method
- Within the
@BeforeEach
annotated setup method - Within non-static inner classes, annotated within the
@Nested
annotation to create a test hierarchy and express relationships among several groups of tests.
Nested classes¶
Non-Static inner classes are the preferred method for declaring these values and running tests.
@BeforeEach
and @AfterEach
working inside nested tests. However, @BeforeAll
and @AfterAll
do not work by default as static members within inner classes are not allowed, unless a class lifecycle mode is setup within the class.
Within the inner class, there can be tests that use members of the outer class, initialised in the outer setUp method, as well as members of the inner class. Nesting can be arbitrarily deep, so tests can contain tests, so long as it is annotated using @Nested
.
Behaviour-Driven Development (BDD)¶
An application is specified and designed by describing how it should behave.
BDD aligns with the Test Phases listed earlier:
Test Phases | BDD |
---|---|
Arrange | Given |
Act | When |
Assert | Then |
Example¶
Given an action, when an action is performed, then expect a result.
Display Names¶
@DisplayName annotation¶
@DisplayName
can be used at the class and method level to change the name of the test to any unicode string. This enables a developer to create a framework based around a natural language and program accordingly. It also increases readability at test completion.
@DisplayNameGeneration annotation¶
@DisplayNameGeneration
can be used to declare a custom DisplayName generator for the test class. These can be created by implementing the interface DisplayNameGenerator
.
Simple
= The default behaviour, removes trailing parentheses for methods with no parameters.ReplaceUnderscores
- replaces underscores in method names with spaces.IndicativeSentences
, generates complete sentences by concatenating the names of the test methods and the enclosing classes.
Assertions¶
A test is a boolean operation, it only returns true or false.
A good test should only have one act and assert phase per operation.
A test should have one action followed by one or more physical assertions that form a single logical assertions about the action.
JUnit includes many other methods to evaluate if an expected outcome has been achieved.
External assertion libraries can also be used, such as AssertJ and Hamcrest.
Assertion Usage¶
In most cases, assertion methods take an expected outcome, the actual outcome, and optionally a message string, as inputs.
assertEquals¶
When using multiple assertions within a test, if one assertions fails, the rest within that test will not be executed, to indicate a failure.
assertNotNull(info);
assertEquals(2, info.getDiscount());
assertEqual(10, info.getPointsRedeemed());
Alternatively, a lambda expression can be used with assertAll to wrap assertions, in this instance, even if an assertion fails the others will continue to be executed.
assertAll("Heading Text",
() -> assertNotNull(info),
() -> assertEquals(2, info.getDiscount()),
() -> assertEquals(10, info.getPointsRedeemed())
);
assertThrows¶
It it possible to test for invalid / illegal values via the usage of assertThrows
. assertThrows
can be used to assert the execution of a supplied lambda and that the exception is of the expected type. If no exception or an exception of a different type is thrown, this method will fail.
assertDoesNotThrow¶
To test if no exception is thrown, use assertDoesNotThrow
assertTimeout¶
assertTimeout
is used to test if a supplied lambda expression completes before the specified timeout duration is reached.
RewardInformation info = assertTimeout (
Duration.ofMillis(4),
() ->
reward.applyReward(
buildSampleOrder(numberOfProducts),
200)
);
If a timeout is exceeded, the test operation still completes. In order to timeout the test operation in full if the duration is exceeded then assertTimeoutPreemptively
can be used. This will also run the lambda in a different thread.
Disabling Tests¶
To disable individual methods and classes fully within JUnit 5, use the @Disabled
annotation.
Annotation-Based Conditions¶
However, JUnit 5 also provides annotations that can be used to target specific conditions. For example
Enable | Disable | Description |
---|---|---|
@EnableOnOS( { LINUX, MAC }) | @DisableOnOS(WINDOWS ) | Enable / Disable test depdendant on the OS. |
@EnableOnJre(JAVA_8 ) | @DisableOnJre({ JAVA_9, JAVA_10 }) | Enable / Disable test depending on the version of the JRE. |
@EnableForJreRange(max=JAVA_10 ) | @DisableForJreRange(min=JAVA_11,max=JAVA_14 ) | Enable / Disable test for a given range of JREs. |
@EnableIfSystemProperty(named="version, matches=1.4 ") | @DisableIfSystemProperty(named="dev", matches="true ") | Enable / Disable test based upon the value of a JVM system property. |
@EnabledIfEnvironmentVariable(named="ENV", matches="*server") | @DisabledIfEnvironmentVariable(named="ENV", matches="QA" ) | Enable / Disable test based upon the value of an e nvironment variable. |
@EnabledIf("methodName" ) | @DisabledIf("com.example.MyClass#myMethod ") | Enable / Disable test based on the boolean return value of a method specified with its name of its FQN, if it is defined outside of the test class. |
Example¶
@Test
@DisplayName("Should not exceed timeout")
@Disabled("Optimisation not yet implemented")
void timeoutNotExceeded() {
int numberOfProducts = 50_000;
reward.setGiftProductId(numberOfProducts - 1);
RewardInformation info = assertTimeoutPreemptively(
Duration.ofMillis(4),
() ->
reward.applyReward(
buildSampleOrder(numberOfProducts),
200
)
);
assertEquals(2..99, info.getDiscount());
}
Assumptions¶
Assumptions are an easy way to conditionally stop the execution of the test. For example, if the test depends on something that doesn't exist in the current environment.
Failed assumptions do not result in a test failure. A failed assumption simply aborts the test.
Types of assumptions¶
assumeTrue¶
assumeTrue
- Validates if the given assumption evaluates to true to allow the execution of the test.
The assumption can be a a boolean expression of a lambda expression that represents the functional interface BooleanSupplier
assumeTrue(boolean assumption)
assumeTrue(boolean assumption, String message)
assumeTrue(BooleanSupplier assumptionSupplier)
assumeTrue(boolean assumption, Supplier<String> message)
assumeTrue(BooleanSupplier assumptionSupplier, String message)
assumeTrue(BooleanSupplier assumptionSupplier, Supplier<String> message)
assumeFalse¶
assumeFalse
validates if the given assumption evaluates to false to allow the execution of the test.
assumeFalse(boolean assumption)
assumeFalse(boolean assumption, String message)
assumeFalse(BooleanSupplier assumptionSupplier)
assumeFalse(boolean assumption, Supplier<String> message)
assumeFalse(BooleanSupplier assumptionSupplier, String message)
assumeFalse(BooleanSupplier assumptionSupplier, Supplier<String> message)
assumingThat¶
Executes the supplied lambda expression that represents the functional interface Executable
only if the given assumption evaluates to true.
assumingThat(boolean assumption, Executable executable)
assumingThat(BooleanSupplier assumptionSupplier, Executable executable)
Example¶
@Test
@DisplayName("When empty order and enough points no rewards should be applied")
void emptyOrderEnoughPoints() {
RewardInformation info = reward.applyReward(getEmptyOrder(), 200);
assertEquals(0, info.getDiscount());
assumeTrue("1".equals(System.getenv("TEST_POINTS")));
// Only execute the next assert if the above assumption is valid
assertEquals(0, info.getPointsRedeemed());
}
@BeforeEach
void setUp() {
reward = new RewardByConversionService();
reward.setAmount(10);
reward.setNeededPoints(100);
assumeTrue("1".equals(System.getenv("TEST_POINTS")))
}
@Test
@DisplayName("When empty order and enough points no rewards should be applied")
void emptyOrderEnoughPoints() {
RewardInformation info = reward.applyReward(getEmptyOrder(), 200);
assertEquals(0, info.getDiscount());
assumingThat("1".equals(System.getenv("TEST_POINTS")),
() -> {
assertEquals(10, info.getPointsRedeemed());
});
}
Test Interfaces and Default Methods¶
With JUnit 5, methods annotated with @Test
, @BeforeEach
and @AfterEach
can be moved to an interface and then have the test class implemented to us those methods.
Other annotations such as @RepeatedTest
, @ParameterizedTest
, @TestFactory
, @TestTemplate
, @ExtendWith
, @Tag
, and many others, can also be implemented within an interface.
As a special case, methods annotated with @BeforeAll
and @AfterAll
must be static.
Anything that can be used within a test class can also be moved to an interface.
Repeating Tests¶
JUnit provides the @RepeatedTest
annotation to repeat a test. The number of repetitions is fixed, it can not be changed at runtime. Each invocation of a repeated test behaves like a regular test method with full lifecycle support.
Note
@RepeatedTest
substitutes the @Test
annotation. If @Test
is included alongside @RepeatedTest
, JUnit will generate an error at runtime.
Custom Display Names¶
A custom display name can be assigned to each repetition of the test.
Placeholders¶
- {displayName} - The displayName of the current test method
- {currentRepetition} - The iteration of the current test being run
- {totalRepetitions} - The total iterations of tests to be run
Long Display Name¶
RepeatedTest.LONG_DISPLAY_NAME
{displayName} :: repetition {currentRepetition} of {totalRepetitions}
// My Test :: repetition 1 of 10
Short Display Name (Default)¶
RepeatedTest.SHORT_DISPALY_NAME
repetition {currentRepetition} of {totalRepetitions}
// repetition 1 of 10
RepetitionInfo Interface¶
@RepeatedTest
, @BeforeEach
and @AfterEach
can be passed an object of type RepetitionInfo
as a parameter.
RepetitionInfo
is an interface with the method int getCurrentRepetition();
and int getTotalRepetitions();
Example¶
@RepeatedTest(5) // Annotated to repeat the test 5 times
void methodOne(){
...
}
@RepeatedTest(value = 5, name = "{displayName} - > {currentRepetition}/{totalRepetitions}") // Repeats five times and outputs the name of each iteration.
// Note that the value and the name have to be specified using attributes
@DispalyName("Test Two")
void methodTwo() {
...
}
@RepeatedTest(value = 5, name = "{displayName} - > {currentRepetition}/{totalRepetitions}")
@DisplayName("Test Three")
void methodThree(RepetitionInfo repetitionInfo) { // Inject type RepetitionInfo from JUnit. This can be used within the test method. In the example below, it is used to set the productId + 1000.
long productId = repetitionInfo.getCurrentRepetition() + 1000;
System.out.println(productId);
reward.setGiftProductId(productId);
...
private long getRandomProductIdNotInOrder() {
Random r = new Random();
return r.longs(1000,2000).findFirst().getAsLong();
}
}