Although Rich Model ref seems like a better approach from OOP evangelists' standpoint, Anemic Model ref stays prevailing among enterprise developers. While not being very active in OOP debates, I find Anemic architecture very harmful for testing. To support this claim let me start with a small example of anemic code (full code):
//As seen from the name - this is a representative of the Service Layer
class PersonService {
//Do some calculations on the Person's data
PersonStatistics getStatistics(long personId) {
//using PersonDao to load entity from DB
Person p = personDao.get(personId);
PersonStatistics result = new PersonStatistics(p);
// The actual "calculations"
if(CollectionUtils.isNotEmpty(p.getRelatives()))
result.setNumOfRelatives(p.getRelatives().size());
if(CollectionUtils.isNotEmpty(p.getProjects()))
result.setNumOfProjects(p.getProjects().size());
// Some weird logic just for the demo - storing stats in DB if they are not empty
if((result.getNumOfProjects() != null && result.getNumOfProjects() != 0)
|| (result.getNumOfRelatives() != null && result.getNumOfRelatives() != 0))
//using StatisticsDao to save results to DB
statsDao.save(result);
return result;
}
}
The logic here is fully concentrated in the service layer. It supposedly should be a Façade that just invokes the actual logic - but in our case it implements all of it. And the rest of the objects are just data structures which is a sign of anemic model. There are couple of ways we can write tests for this.
In our first attempt we're going to use mocks. Here are some emerging patterns (full code):
@Test
public class PersonServiceTest {
public void returnsProjectCount_ifProjectsArePresent() {
Person p = new Person().setProjects(projects());
//Mocking PersonDao!
doReturn(p).when(personDao).get(1L);
PersonStatistics statistics = service.getStatistics(1L);
assertEquals(statistics.getNumOfProjects(), (Integer) p.getProjects().size());
}
public void savesStatistics_ifProjectsArePresent() {
Person p = new Person().setProjects(projects());
//Mocking PersonDao!
doReturn(p).when(personDao).get(1L);
service.getStatistics(1L);
//Verifying that PersonDao was invoked by the service
//We may also want to check the actual values of the stats,
//but let's keep it simple here
verify(statsDao).save(any(PersonStatistics.class));
}
}
There are 2 types of tests here - those that test the conditions and calculations and those that check the collaboration with other objects. Mocked depended-on objects (DOCs) lead to problems though:
statsDao.saveOrUpdate()
instead of statsDao.save()
- several tests would have
to be updated while the behaviour didn’t change.As a result by introducing mocking we worsened the maintainability of our tests as well as decreased the coverage ref ref. But we can fix that - instead of using mocks and writing unit tests we’ll write Component (a.k.a. Integration) Tests.
So now our tests initialize a part of the application, inject the real DOCs and we check it all together (full code):
//Loads a part of the application declared in dao-context.xml
@ContextConfiguration("classpath:/dao-context.xml")
@Transactional @Test
public class PersonServiceComponentTest extends AbstractTestNGSpringContextTests {
// Fully initialized Service with real dependencies
@Autowired PersonService service;
@Autowired StatisticsDao statsDao;
@Autowired PersonDao personDao;
public void returnsProjectCount_ifProjectsArePresent() {
//Saves Person object to DB
Person p = save(new Person().setProjects(projects());
//Will actually update DB state
PersonStatistics statistics = service.getStatistics(p.getId());
assertEquals(statistics.getNumOfProjects(), (Integer) p.getProjects().size());
}
public void savesStatistics_ifProjectsArePresent() {
//Again - saves Person to DB
Person p = save(new Person().setProjects(projects());
service.getStatistics(p.getId());
//Uses real DAO to actually perform SQL queries
assertNotNull(statsDao.getPersonStats(p.getId()));
}
}
Now we test the real stuff so we improved the coverage. If it works in tests - it works in prod (at least the scenarios that we came up with). But the price is high:
So while we improved the coverage we paid with the team’s performance.
Let's recap:
It's time for a real solution - Rich Model. The rule is simple - if logic can be put into lower layers of the app, it should be done. Here is a modified code (full code PersonService, Person, PersonStatistics):
class PersonService {
PersonStatistics getStatistics(long personId) {
Person p = personDao.get(personId);
//Stats are now built by Person object
PersonStatistics result = p.buildStatistics();
//PersonStatistics can tell if it's empty or not
if(result.isNotEmpty()) statsDao.save(result);
return result;
}
}
// Domain Model
class Person {
// This can be tested without going to DB
PersonStatistics buildStatistics() {
PersonStatistics result = new PersonStatistics(this);
if (CollectionUtils.isNotEmpty(getRelatives()))
result.setNumOfRelatives(getRelatives().size());
if (CollectionUtils.isNotEmpty(getProjects()))
result.setNumOfProjects(getProjects().size());
return result;
}
}
class PersonStatistics {
// This can also be tested without DB
boolean isNotEmpty() {
return !((numOfProjects == null || numOfProjects == 0)
&& (numOfRelatives == null || numOfRelatives == 0));
}
}
Now we can easily write unit tests for the business logic (full code PersonStatisticsTest, PersonTest):
@Test // No database, no mocks!
public class PersonStatisticsTest {
public void statsAreNotEmpty_ifProjectsArePresent() {
PersonStatistics stats = new PersonStatistics(null)
.setNumOfProjects(positiveInteger());
assertTrue(stats.isNotEmpty());
}
}
@Test // No database, no mocks!
public class PersonTest {
public void returnsProjectCount_ifProjectsArePresent() {
Person p = new Person().setProjects(projects());
PersonStatistics statistics = p.buildStatistics();
assertEquals(statistics.getNumOfProjects(), (Integer) p.getProjects().size());
}
}
And of course we should also check if classes still work together via couple of Component Tests (full code):
@Transactional @Test @ContextConfiguration("classpath:/dao-context.xml")
public class PersonServiceComponentTest extends AbstractTestNGSpringContextTests {
public void savesStatistics_ifNotEmpty() {
//saving to real DB
Person p = save(new Person().setProjects(projects()));
service.getStatistics(p.getId());
assertNotNull(statsDao.getPersonStats(p.getId()));
}
}
Note, that we didn’t get rid of Component Tests, but decreased their number. This is a step towards the all important Test Pyramid. So finally we got what we wanted:
An architecture where the logic is kept near the data - in enterprise apps that would be Entities and/or auxiliary classes. Such classes represent the domain we work in and usually are not instantiated/injected by Dependency Injection mechanisms.
There are debates about whether persistence logic should be put directly into the Rich Model, but in this article we'll be keeping only the business logic there.
An approach that suggests keeping the logic in Services or other stateless classes. The Entities become just structures that group data together but they don't implement any of the business logic. This architecture is opposite to Rich Model.
Read the original post by Fowler.
Note, that mocking can still be valuable when talking about system boundaries - testing an application with real integrations is often too complicated and should be limited to a small suite. The rest of the tests that require external integrations should use mocks.
But to mock external systems it's better to write mocked classes - mocking frameworks will only add duplication and thus will complicate the overall testing. But this deserves an article of its own.
You're probably familiar with Line and Branch coverage, but Code Coverage is a broader term that describes how good/bad your tests check production code. In this article if you see "code coverage" it means an abstract, ultimate coverage - not a particular kind of it.
Line/Branch coverage are popular not because they are good metrics, but because they are easy to measure. In reality you cannot rely on these types of coverage if they are used by themselves. They can tell you which parts of the code are not covered completely, but they fail to tell which parts are covered and what's the quality of the coverage. If you decide to keep track of these metrics, at least introduce mutation testing as well.