-
Notifications
You must be signed in to change notification settings - Fork 313
Unit Test Guidelines and Recipes
This document is a guideline for writing unit tests. If you are in a particular situation where these guidelines don't apply feel free to veer away from them. (Maybe contribute a new recipe?)
Before moving on be sure to read our Gherkin Tests Guidelines. All our tests should follow that format.
Rule: Use the following guidelines for test case naming and method/function names.
@Test
[methodName]Should[Action]With/When[Conditions]()
## Example ##
@Test
applyShouldNotDisableLoopbackDeviceWithOldVersionOfNM()
given[Something]With(params);
## Example ##
givenMockedDeviceWith("1-5", "ttyACM17", NMDeviceType.NM_DEVICE_TYPE_MODEM);
givenNetworkConfigMapWith("net.interfaces", "1-5,");
when[methodName]IsCalled[With]();
## Example ##
whenApplyIsCalledWith(this.netConfig);
whenBuildIpv4SettingsIsCalledWith(this.networkProperties, "wlan0");
whenGetInterfacesIsCalled();
Due to the wide range of potential "then" methods, it is difficult to express a general guideline.
then[methodName]IsCalled();
then[Action]Occurs();
thenValueIs(“value“);
thenNoExecptionsOccured();
## Example ##
thenNoExceptionWasThrown();
thenNullPointerExceptionWasThrown();
thenNetworkSettingsDidNotChangeForDevice("eth0");
-
Test case names, methods and functions should read like English.
-
Any non-technical person should be able to read a test and have a vague understanding of how they work.
-
We should have a consistent naming convention to make it easier for us to understand at a glance what the unit test is doing.
Note: the max method name is 65535+ characters, so do not be afraid to make the method names long. Self-documenting code.
@Test
public void applyShouldDisableGPSWithMissingGPSConfiguration() throws DBusException, IOException {
givenBasicMockedDbusConnector();
givenMockedDeviceWith("1-5", "ttyACM17", NMDeviceType.NM_DEVICE_TYPE_MODEM, NMDeviceState.NM_DEVICE_STATE_ACTIVATED,
true, false, false);
givenMockedDeviceList();
givenNetworkConfigMapWith("net.interfaces", "1-5,");
givenNetworkConfigMapWith("net.interface.1-5.config.ip4.status", "netIPv4StatusEnabledWAN");
givenNetworkConfigMapWith("net.interface.1-5.config.dhcpClient4.enabled", true);
givenNetworkConfigMapWith("net.interface.1-5.config.apn", "myAwesomeAPN");
whenApplyIsCalledWith(this.netConfig);
thenNoExceptionWasThrown();
thenConnectionUpdateWasCalledFor("ttyACM17");
thenActivateConnectionWasCalledFor("ttyACM17");
thenLocationSetupWasCalledWith(EnumSet.of(MMModemLocationSource.MM_MODEM_LOCATION_SOURCE_3GPP_LAC_CI), false);
}
Rule: Prefer static data in expected values.
Motivation:
-
If we replicate the code logic in our tests we would need unit tests for our unit tests.
-
The unit tests should be our single source of truth about the expected output given an input.
-
One exception might be for alternative implementations: if you have a method that computes the inverse square root of a real number, you might want to test it against a known good implementation of the inverse square root. I.e. there’s a single source of truth for the expected values, which is the output of the known good implementation.
public int add(int a, int b) {
return a + b;
}
@Test
public void addShouldWorkWithTwoIntegers() {
whenAddIsCalledWith(2, 2);
// thenResultEquals(2+2); <- WRONG! It's like calling add(2, 2)!
thenResultEquals(4);
}
Rule: Instead of having gigantic givens method without any parameter, try and parametrise these methods in such a way that the parameters of the given methods match the inputs of the test case.
Motivation:
-
Assigning static values in method invocation as parameters are preferred, so when reading the tests, you understand exactly what happens. This makes tests more flexible, and easier to understand.
-
They are more flexible
-
Parameterisation + Reuse > Magic Methods
-
Promotes code reuse
-
Better understandability/readability of what are the inputs of a test case
Link to the actual code: https://github.com/eclipse/kura/blob/7a54e07345b680e9df85bc53c76a6e0ef0416dd1/kura/test/org.eclipse.kura.nm.test/src/test/java/org/eclipse/kura/nm/NMDbusConnectorTest.java
class networkImplTest{
@Test
void testWifiBadExample(){
givenNetworkWifiAdapterWithNetworkAndWanSupport();
}
@Test
void testWifiGoodExample(){
givenNetworkAdapterWith("wlan0", Type.Wifi, EnabledForWan);
}
@Test
void EthernetBadExample(){
givenNetworkEthernetAdapterWithNetworkAndWanSupport();
}
@Test
void EthernetGoodExampleWithMultipleDevices(){
givenNetworkAdapterWith("eth0", Type.Ethernet, EnabledForWan);
givenNetworkAdapterWith("eth1", Type.Ethernet, EnabledForWan);
givenNetworkAdapterWith("wlan0", Type.Wifi, Disabled);
}
}
Notice how the given methods can be used across multiple test cases, and keep the document extremely clean and readable.
Rule: Rely on JUnit cleaning mechanism.
Motivation:
-
Rely on JUnit cleaning mechanism.
- Instance variables in a Class will be cleaned/reset at the start of every test.
Reference:
public class negativeExampleTest {
private Map<String, Object> properties;
private Options options;
private Object returnValue;
@Test
public void shouldReturnEnabled() {
givenProperty("something", true);
givenOptionsInstatiated();
whenIsEnabled();
thenReturnValueIs(true);
}
@Before
public void cleanUp() { // WRONG! This is not needed!
this.properties = new HashMap<>();
this.options = null;
this.returnValue = null;
}
}
Rule: Mock the underlying libraries so that we can test the interfacing code
Motivation:
-
Think about the goal of what you are testing. Remember to test the code we wrote, not the underlying libraries. Never be afraid to MOCK!
-
Sometimes can be hard to understand where our code ends and the underlying library code starts. Keep in mind that we should test our code not others. We rely on the fact that libraries are working as expected.
-
The line between integration tests and unit tests
-
We mock the libraries so that we can appropriately test how our code interfaces with them.
When interfacing with an external service through a library (eg. NetworkManager via dbus-java, Docker via Docker-java) you might be tempted to test the entire system. This is wrong: you want to test the code that interfaces with the library, not the library itself.
Examples:
-
Here we mocked the library so that we can check that the Kura interfacing code actually works https://github.com/eclipse/kura/blob/f4188104b63b743c37a0b623177fb69d8367d85b/kura/test/org.eclipse.kura.container.orchestration.provider.test/src/test/java/org/eclipse/kura/container/orchestration/provider/ContainerOrchestrationServiceImplTest.java#L381
-
Here we mocked the Dbus data structures so we can focus on how our code interacts with ithttps://github.com/eclipse/kura/blob/f4188104b63b743c37a0b623177fb69d8367d85b/kura/test/org.eclipse.kura.nm.test/src/test/java/org/eclipse/kura/nm/NMDbusConnectorTest.java
Rule: Do not call the methods of the objects you mocked in the tests. These methods should only be called by the production code you’re testing
Motivation:
-
What if an expectation depends on a method being called only once? We’re changing the results by inspecting them.
-
This introduces side-effects to your methods
Examples:
-
Here the mocked dBusConnection is called to retrieve the connection associated to the interface we’re checking https://github.com/eclipse/kura/blob/e292e5aca78d99c79308df302b699a4a8cc68fb6/kura/test/org.eclipse.kura.nm.test/src/test/java/org/eclipse/kura/nm/NMDbusConnectorTest.java#L1430
-
Here we’re calling the GetDeviceByIpIface method of the mocked object https://github.com/eclipse/kura/blob/e292e5aca78d99c79308df302b699a4a8cc68fb6/kura/test/org.eclipse.kura.nm.test/src/test/java/org/eclipse/kura/nm/NMDbusConnectorTest.java#L1275 The correct way to handle it, since we built the mocked object and the object returned by the mocked object, would be to keep track of the dbus path associated with that interface in another structure.
Strategies you can directly apply to your unit tests.
When testing a Singleton class use reflection to provide that class with any required mocked libraries.
Please check out the Example Here.
One way to increase the feature coverage of a class is to develop tests that cover as many paths or uses as possible. Since it is not possible or efficient to replicate a test changing only the inputs or some internal variable, the parametric testing can be used to increase the code/feature coverage in an easy way. A parametric test is a test that is executed multiple times with different parameters.
An example of this feature can be found below, extracted from the https://github.com/eclipse/kura/blob/develop/kura/test/org.eclipse.kura.nm.test/src/test/java/org/eclipse/kura/nm/MMSimTypeTest.java . The test is compatible with JUnit4.
@RunWith(Parameterized.class)
public static class MMSimTypeToMMSimTypeTest {
@Parameters
public static Collection<Object[]> SimTypeParams() {
List<Object[]> params = new ArrayList<>();
params.add(new Object[] { new UInt32(0x00), MMSimType.MM_SIM_TYPE_UNKNOWN });
params.add(new Object[] { new UInt32(0x01), MMSimType.MM_SIM_TYPE_PHYSICAL });
params.add(new Object[] { new UInt32(0x02), MMSimType.MM_SIM_TYPE_ESIM });
params.add(new Object[] { new UInt32(0x14), MMSimType.MM_SIM_TYPE_UNKNOWN });
return params;
}
private final UInt32 inputIntValue;
private final MMSimType expectedSimType;
private MMSimType calculatedSimType;
public MMSimTypeToMMSimTypeTest(UInt32 intValue, MMSimType simType) {
this.inputIntValue = intValue;
this.expectedSimType = simType;
}
@Test
public void shouldReturnCorrectMMSImType() {
whenCalculateMMSimType();
thenCalculatedMMSimTypeIsCorrect();
}
private void whenCalculateMMSimType() {
this.calculatedSimType = MMSimType.toMMSimType(this.inputIntValue);
}
private void thenCalculatedMMSimTypeIsCorrect() {
assertEquals(this.expectedSimType, this.calculatedSimType);
}
}
The @RunWith(Parameterized.class) annotation signals that the following test is a parametric one and it should be run multiple times with the parameters described with the @Parameter annotation.
This contains a collection on Object[] with the parameters to be passed to the test constructor (MMSimTypeToMMSimTypeTest(UInt32 intValue, MMSimType simType)).
The test in the test class can follow the usual Gerkin style.
Since the parameters are passed to all the test methods inside the class, it is useful to aggregate multiple test suites (parametric or not) in a single class. To achieve this, the Enclosed or Suite runners can be used. An example is shown below:
@RunWith(Enclosed.class)
public class MMSimTypeTest {
@RunWith(Parameterized.class)
public static class MMSimTypeToMMSimTypeTest {
...
}
@RunWith(Parameterized.class)
public static class MMSimTypeToSimTypeTest {
...
}
In this case, two (parametric) tests are aggregated together and different sets of parameters are applied to the tests.
There are basically two different approaches, the usual try-catch method and the usage of JUnit APIs. We opted for using the try-catch approach since it is easier to understand and allows to easily check for side effects.
The usual pattern will be as follows:
private Exception occurredException;
...
@Test
public void myTestShouldThrowKuraException() {
givenX();
whenMyMethodIsCalled();
thenExceptionOccurred(KuraException.class);
thenDirtyFileWasRemoved();
}
...
private void whenMyMethodIsCalled() {
try {
this.testedObject.myMethod();
} catch (Exception e) {
this.occurredException = e;
}
}
...
private void thenNoExceptionOccurred() {
String errorMessage = "Empty message";
if (Objects.nonNull(this.occurredException)) {
StringWriter sw = new StringWriter();
this.occurredException.printStackTrace(new PrintWriter(sw));
errorMessage = String.format("No exception expected, \"%s\" found. Caused by: %s",
this.occurredException.getClass().getName(), sw.toString());
}
assertNull(errorMessage, this.occurredException);
}
private <E extends Exception> void thenExceptionOccurred(Class<E> expectedException) {
assertNotNull(this.occurredException);
assertEquals(expectedException.getName(),
this.occurredException.getClass().getName());
}
In the example above, we have the then clause that compares for equality of the exceptions class names. Using this approach, when the test fails we have logged the expected output and the actual one directly by JUnit. It is possible to integrate the error message with the method assertEquals(String message, Object o1, Object o2); like, for example, embedding the stack trace.
User Documentation: https://eclipse-kura.github.io/kura/. Found a problem? Open a new issue or start a discussion.