A lot has been already written about Java, Unit Testing and Integration Testing, even Behavioral Testing. There are opinions I did and did not adapt as my own.
This sums up what I consider to be a good practice and how I like to lead my Dev work.
Unit Testing
The primary goal of Unit Testing (UT) is to take the smallest piece of testable software in the application, isolate it from the remainder of the code, and determine whether it behaves exactly as expected. Each unit is tested separately before integrating them into modules to test the interfaces between modules.
When Unit Testing, each test should exercise a single scenario or a single code path. Unit Tests test class by class, method by method, code path by code path. If a tested class or method has a Dependency, it has to be:
- stubbed
- mocked
- faked
- dummied
- spied
The five just named together form a set of Test Doubles. It is best to look at an example LoginService
to be tested:
public class LoginServiceImpl implements LoginService {
private final UserDao dao;
public LoginServiceImpl(UserDao dao) {
this.dao = dao;
}
@Override
public boolean isValidLogin(String login) {
User user = dao.findByLogin(login);
return user != null;
}
}
One can approach its dependencies (or collaborators) injection during the test in two ways:
- use Mocks
public class LoginServiceTest {
private static final String VALID_LOGIN = "jan";
private static final String NO_SUCH_LOGIN = "no-such-login";
private LoginService service;
private UserDao dao;
@Before
public void setUp() {
dao = createMock(UserDao.class); // could be just annotated with @Mock
service = new LoginServiceImpl(dao);
}
@After
public void after() {
// verifies that records expected have been called during the test
verify(dao);
}
@Test
public void testIsValidLogin_Null() {
// record phase of the mock - what calls are expected to by called and what the result should be
expect(dao).findByLogin(null).andReturn(null);
// record phase finished, replay and expect methods to be called
replay(dao);
assertFalse(service.isValidLogin(null));
}
@Test
public void testIsValidLogin_NoSuchLogin() {
expect(dao).findByLogin(NO_SUCH_LOGIN).andReturn(null);
replay(dao);
assertFalse(service.isValidLogin(NO_SUCH_LOGIN));
}
@Test
public void testIsValidLogin_ExistingLogin() {
expect(dao).findByLogin(VALID_LOGIN).andReturn(new User(VALID_LOGIN));
replay(dao);
assertTrue(service.isValidLogin(StubUserDao.VALID_LOGIN));
}
}
- use real implementations as the dependencies if possible or create test Stubs
public class LoginServiceTest {
private static final String NO_SUCH_LOGIN = "no-such-login";
private static final String VALID_LOGIN = "jan";
private LoginService service;
@Before
public void setUp() {
service = new LoginServiceImpl(new StubUserDao());
}
@Test
public void testIsValidLogin_Null() {
assertFalse(service.isValidLogin(null));
}
@Test
public void testIsValidLogin_NoSuchLogin() {
assertFalse(service.isValidLogin(NO_SUCH_LOGIN));
}
@Test
public void testIsValidLogin_ExistingLogin() {
assertTrue(service.isValidLogin(VALID_LOGIN));
}
private static final class StubUserDao implements UserDao {
@Override
public User findByLogin(String login) {
if(LoginServiceTest.VALID_LOGIN.equals(login)) {
return new User(login);
}
return null;
}
}
}
A must read article on this topic is Martin Fowler's Mocks arent Subs article.
Stub Pros & Cons
- Pros
- it is a plain Java implementation of an interface you already know and use
- they are reusable
- Cons
- a single Test scenario approach can result in many Stub implementations
- a change to the interface results in update of your Test Stubs
- the Stub must implement all the interface methods, even thought you are not using them
- refactoring of a Stub may break multiple tests when you reuse your Stubs
- sometimes they are not reusable
- they are reusable to an extent, when you do not have to create Test specific code paths inside the Stub itself
Mock Pros & Cons
- Pros
- no need to maintain Test specific interface implementations in your code
- you only "implement" what you need to record for a specific test
- mocks verify Test behavior and hold a state per Test
- Cons
- even though it is a simple Java code, it does not look simple on a first look
- mocks have some learning curve, although very small
Mocking or Stubbing?
I am inclined to call myself a Mocker. Not that Practical or Classic approach to Unit Testing should be completely avoided, I prefer Mocking to Stubbing and here is why:
- If your dependency graph is deeper than a single level, you need to take care of wiring multiple levels of your dependencies for each test. Since Unit Tests must be isolated from the environment including your DI container such as Spring, you need to do this manually. DI is not what you should be focused on when Unit Testing.
- Real implementations cannot be always used, they may use external resources, call other systems, use deployment environment specific resources, etc. To isolate your UT, you need to create a Test implementation of your Dependency or a Stub. This is often trivial, but isn't a Mock such an implementation of a Dependency? Instead of writing and maintaining additional Stub implementations, use Mocks to record what your system should do. Expected behavior of the Dependency is then clear from the Test itself.
- System Under Test (SUT) is then clearly defined whereas with the Classic approach the UT is, in fact, testing its dependencies as well.
- Mocks usually fail with a descriptive message when their expectations have not been fulfilled during the Test. It helps investigating the source of a Test failure. Stub Implementations have to handle such situations manually.
Conclusion? In real life applications testing, you will probably end up using both Mocks and Stubs. Given reasons above, pick Mocks before Stubs.
Important! You do not need any Dependency Injection (DI) container such as Pico, Guice or Spring to write Unit Tests.
Test Driven Development (TDD)
Thanks to this technique, there will be no such thing as "I have no time to write Unit Tests". If you do not write UT, you will try to print debugging messages or do some similar hacking around anyway.
Very good reading about TDD can be found at MSDN. In short, TDD is about constantly iterating between the UT and actual Production code in incremental steps:
- write a UT and see it fail
- write the actual code and make the test pass
- if the code is unfinished, goto 1.
This way your code becomes naturally well Unit Tested with excellent coverage, because you write a UT for the given code path first.
Integration Testing
When writing an Integration Test (IT), you wire up multiple dependent units and test them in the context of their surrounding infrastructure. It is essential to distinguish between:
- connected IT
- uses real resources and services
- often created while integrating with databases or APIs
- connected IT usually not a part of your standard build test suite, as you cannot predict the availability of the dependent resource, its timely response or even stability of data returned
- isolated (disconnected) IT
- similarly to Unit Tests, IT uses Mocks, Stubs and Dummies
- Test Double’s goal is to replace the dependent resource with a stable, predictable and repeatable result
- isolated IT should be a part of your build test suite
Sometimes it is a bit challenging to identify a SUT when writing an IT. If you integration test a single Service
or Adapter
in your code base, than the SUT is your ServiceIT
or AdapterIT
. When you integration test your application via its public endpoints (e.g. an API by calling its operations or a Web Application by interacting with it), the SUT may be the tested endpoint itself or more likely the whole application.
Integration Testing a Spring application
Spring comes with a whole module to support IT called spring-test
. Contains JUnit and TestNG support classes.
IT with JUnit and Spring
SpringJUnit4ClassRunner
class for JUnit to use with its @RunWith
annotations
@ContextConfiguration
annotation to specify Spring @Configuration
annotated classes
Let the application use ApplicationConfiguration
class to define live DataSource @Bean
configuration:
@Configuration
public class ApplicationConfiguration {
@Bean
public DataSource dataSource() {
BoneCPDataSource ds = new BoneCPDataSource();
ds.setDriverClass("com.mysql.jdbc.Driver");
ds.setJdbcUrl("jdbc:mysql://localhost:3306/batman");
ds.setUsername("Bruce");
ds.setPassword("Wayne");
return ds;
}
}
Its @Configuration
override for IT purposes redefines the dataSource @Bean
, it may also populate the database with fresh data for each @Test
or Test Suite:
@Configuration
// @Import the Live configuration, there may be more than just the DataSource
@Import(ApplicationConfiguration.class)
public class ITApplicationConfiguration {
@Bean
public DataSource dataSource() {
BoneCPDataSource ds = new BoneCPDataSource();
ds.setDriverClass("org.hsqldb.jdbc.JDBCDriver");
ds.setJdbcUrl("jdbc:hsqldb:mem:test");
ds.setUsername("sa");
ds.setPassword("");
return ds;
}
}
Avoid using different @Configuration
IT classes for different ITs. Spring caches Test Contexts and by using a single Test @Configuration
, you avoid multiple context boots during your IT phase.
IT for the LoginService
class becomes:
@ContextConfiguration(classes = ITApplicationConfiguration.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class LoginServiceIT {
private static final String NO_SUCH_LOGIN = "no-such-login";
private static final String VALID_LOGIN = "jan";
@Inject
private LoginService service;
@Test
public void testExists_Null() {
assertFalse(service.isValidLogin(null));
}
@Test
public void testExists_NoSuchLogin() {
assertFalse(service.isValidLogin(NO_SUCH_LOGIN));
}
@Test
public void testIsValidLogin_ExistingLogin() {
assertTrue(service.isValidLogin(VALID_LOGIN));
}
}
IT with TestNG and Spring
Writing IT for TestNG is a bit more clunkier. There is no such thing as @RunWith support inside the framework. Thus Spring has to fallback to the infamous IT inheritance pattern (criticized by many for example Three Reasons Why We Should Not Use Inheritance In Our Tests or The Benefits of Testable Code) with its AbstractTestNGSpringContextTests
:
Although with TestNG you limit your IT suite due to the given AbstractTestNGSpringContextTests
class, you may still consider it because of its @DataProviders
.
Sources