Testing persistence in the Android ecosystem

Sprint Board Guardian and Project Happiness Keeper. Dog walker, husband and enthusiastic sci-fi reader.

Growing Object-Oriented Software: Guided by Tests is the best book I’ve ever read about testing.
Because it’s not about testing.

Yes, the book talks a lot about clean and readable tests. But tests are just a tool to guide you to create good object oriented software.
The authors walk you through the principles of well-designed object-oriented software and, incidentally, how tests can help you achieve that.

In this post, I want to share what I learnt in Growing Object-Oriented Software: Guided by Tests about testing persistence and how to apply it to the Android ecosystem.


Persistence refers to the ability of an object to survive the process that created it. One way to achieve that goal is by saving or persisting its state in a database.

When dealing with persistence, you will usually have to rely on third-party code to do the heavy lifting for you. A critical point about third-party code is that you don't own it. You need to take it as you find it and focus on the integration between your system and the external code.
When integrating with a third party database API (eg Android SQLiteDatabase) you need to test that your implementation does all these things correctly:

When testing persistence you need to pay extra attention to test quality. There are several components involved that your tests need to set up correctly. And there is always a persistent state that can make your tests interfere with each other.

This example uses a simple TODO app, specifically exploring its persistence layer.

A user can create tasks with a name and an expiration date. The task list can be filtered so only non-expired tasks are displayed.
The persistence layer of the system is represented in the figure below.

Isolate tests that affect persistent state

Persistent data stays around from one test to the next, so you need to take extra care to ensure persistence tests are isolated from one another.

In database tests this means deleting rows before a test starts.
This cleaning process will depend on the database's integrity constraints (foreign keys, cascade deletes, etc.)

Cleaning persistent data on test start and not on test finish has two main advantages:

Database cleaning constraints, like tables delete order, should be captured in one single place, since the database scheme tends to evolve.
You can use DatabaseCleaner to group those constraints. In this example it only contains one table, but you could add more in a real implementation.

public class DatabaseCleaner {

    private static final String[] TABLES = {
            // Add tables to delete here
            TaskEntry.TABLE_NAME
    };

    private final TaskReaderDbHelper dbHelper;

    public DatabaseCleaner(TaskReaderDbHelper dbHelper) {
        this.dbHelper = dbHelper;
    }

    public void clean() throws SQLException {
        SQLiteDatabase sqLiteDatabase = dbHelper.getWritableDatabase();

        for (String table : TABLES) {
            sqLiteDatabase.delete(table, null, null);
        }

        dbHelper.close();
    }
}

It uses a list of tables to ensure correct cleaning order.

Now you can add this to your test suite setup method to clean up the database before each test:

@RunWith(AndroidJUnit4.class)
public class ExamplePersistenceTest {

    @Before
    public void setUp() throws Exception {
        [...]

        DatabaseCleaner cleaner = new DatabaseCleaner(dbHelper);
        cleaner.clean();
    }

    [...]

}

Do commit!

A common testing technique to isolate tests is to run each test in a transaction and roll it back at the end of the test.
The problem with this technique is that it doesn't check what happens on commit.
A database checks integrity constraints on commit. A test that never commits won't be fully checking how the class under test interacts with the database. This committed data could also be useful for diagnosing failures.

So tests should make it obvious when transactions happen.

In this case, every call to TaskRepository.persistTask() will commit to the database directly. That is our transaction boundary.

Testing an object that performs persistence operations

You can use the above previous setup to start testing objects that persist data in a database.

In our domain model, a TaskRepository represents all the operations you can perform around saving or reading: you can add new tasks to the database, find tasks by name and find tasks that are already expired.

public class TaskRepository {  
    [...]
    public void persist(Task task) {...}
    public List<Task> tasksExpiredBy(Date date) {...} 
    public Task taskWithName(String taskName) {...}
}

TaskRepository has two collaborators:

When unit-testing code that uses a TaskRepository as collaborator you can mock the interface directly. There is no need for real database access.

Classes using TaskRepository to persist and query tasks need to trust their collaborators to work correctly. It is the collaborator responsibility to deal with a real database but, from an external point of view, TaskRepository just returns tasks stored somewhere.

However, when testing TaskRepository, you need to be sure it queries and maps objects into the database correctly. In the test below we exercise the tasksExpiredBy method:

@RunWith(AndroidJUnit4.class)
public class TaskRepositoryTest {  
    private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");

    private TaskRepository taskRepository;

    @Before
    public void setUp() throws Exception {
        Context appContext = InstrumentationRegistry.getTargetContext();

        TaskReaderDbHelper dbHelper = new TaskReaderDbHelper(appContext);
        TaskDBStorage storage = new TaskDBStorage(dbHelper);
        TaskMapper mapper = new TaskMapper();

        taskRepository = new TaskRepository(mapper, storage);

        DatabaseCleaner cleaner = new DatabaseCleaner(dbHelper);
        cleaner.clean();
    }

    @Test
    public void findsExpiredTasks() throws Exception {
        String deadline = "2017-01-14";

        addTasks(
                aTask().withName("Task 1 (-Valid-)").withExpirationDate("2017-01-31"),
                aTask().withName("Task 2 (Expired)").withExpirationDate("2017-01-01"),
                aTask().withName("Task 3 (-Valid-)").withExpirationDate("2017-02-11"),
                aTask().withName("Task 4 (-Valid-)").withExpirationDate("2017-02-14"),
                aTask().withName("Task 5 (Expired)").withExpirationDate("2017-01-13")
        );

        assertTasksExpiringOn(deadline,
                containsInAnyOrder(
                        aTaskNamed("Task 2 (Expired)"),
                        aTaskNamed("Task 5 (Expired)"))
        );
    }

    private void addTasks(final TaskBuilder... tasks) {
        for (TaskBuilder task : tasks) {
            taskRepository.persist(task.build());
        }
    }

    private void assertTasksExpiringOn(String deadline, Matcher<Iterable<? extends Task>> taskMatcher) throws ParseException {
        Date date = dateFormat.parse(deadline);
        assertThat(taskRepository.tasksExpiredBy(date), taskMatcher);
    }
}

Interesting things happening in this test:

This test implicitly exercises TaskRepository.persistTask when setting up the database for the query.
The relationship between adding a task and querying that task is something that matters to the app domain logic, so you shouldn’t need to test persistTask independently.

(If there is an effect on the system by persistTask that is not checkable using tasksExpiredBy then you have bigger problems you will need to address before considering adding a separate test for persistTask)

This test shows a very nice example of using custom matchers for better test structure and readability. So, why not learn how to create your own?

Here is an implementation of TaskRepository that passes the test:

public class TaskRepository {  
    private final TaskMapper taskMapper;
    private final TaskStorage taskDBStorage;

    public TaskRepository(TaskMapper taskMapper, TaskDBStorage taskDBStorage) {
        this.taskMapper = taskMapper;
        this.taskDBStorage = taskDBStorage;
    }

    public void persistTask(Task task) {
        TaskDBModel taskDBModel = taskMapper.fromDomain(task);
        taskDBStorage.insert(taskDBModel);
    }

    public List<Task> tasksExpiredBy(Date date) {
        long expirationDate = date.getTime();

        return dbTasksToDomain(taskDBStorage.findAllExpiredBy(expirationDate));
    }

    public Task taskWithName(String taskName) {
        TaskDBModel taskDBModel = taskDBStorage.findByName(taskName);
        return taskMapper.toDomain(taskDBModel);
    }

    @NonNull
    private List<Task> dbTasksToDomain(List<TaskDBModel> allExpiredBy) {
        List<Task> tasks = new ArrayList<>();
        for (TaskDBModel taskDBModel : allExpiredBy) {
            tasks.add(taskMapper.toDomain(taskDBModel));
        }
        return tasks;
    }
}

Testing mappings

Round-trip tests check that the mappings to and from the database are configured correctly.
Mappings can be defined by code or configuration, and errors on those are very difficult to diagnose.

You need to round-trip all the possible object types that could be persisted in the database. For that, you can use a list of test data builders for those types.
You can use these builders more than once, with different setups, to create round-tripping entities in different states.

This test goes through the list of builders, creates and persists an entity in one transaction and retrieves and compares the result in another one.

@RunWith(AndroidJUnit4.class)
public class PersistabilityTest {  
    List<TestBuilder<Task>> persistentObjectBuilders = Arrays.<TestBuilder<Task>>asList(
            // Add different Task configurations here
            aTask().withName("A task").withExpirationDate("2017-03-16"),
            aTask().withName("A task (with date in the past)").withExpirationDate("2017-01-01")
    );

    @Before
    public void setUp() throws Exception {
        [...] // Same as before
    }

    @Test
    public void roundTripsPersistentObjects() {
        for (TestBuilder builder : persistentObjectBuilders) {
            assertCanBePersisted(builder);
        }
    }

    private void assertCanBePersisted(TestBuilder<Task> builder) {
        assertReloadsWithSameStateAs(persistedObjectFrom(builder));
    }

    private void assertReloadsWithSameStateAs(Task original) {
        Task savedTask = taskRepository.taskWithName(original.getName());
        assertThat(savedTask, equalTo(original));
    }

    private Task persistedObjectFrom(TestBuilder<Task> builder) {
        Task original = builder.build();
        taskRepository.persistTask(original);
        return original;
    }

}

Round-tripping related entities

Things get complicated when there are relationships between entities. Database constraints are violated if you try to save an entity without related existing data.
To fix this you need to make sure that related data exists in the database before saving an entity for a round-trip test.

For example, let’s say you were to add Lists to your model so each Task belongs to a List.

Your Task round-trip tests will need to change to insert a dummy List before inserting tasks in the database. A good way to do that is to delegate the creation of related data to another builder. The builder of the entity under test (Task) will use the builder of the other entity it depends on (List) to persist the related data (a dummy list) before the entity under test is persisted.

You could change your tests to include a ListBuilder decorated like this:

private TestBuilder<List> persisted(final TestBuilder<List> listBuilder) {  
    return new TestBuilder<List>() {
        @Override
        public List build() {
            List list = listBuilder.build();
            listRepository.persistList(list);
            return list;
        }
    };
}

See more about round-tripping related entities in this test.

Conclusion

Persistence tests are more complicated than regular ones. They require more setup and scaffolding making them difficult to read, maintain and evolve.

But don’t let that put you off. There are two main things to remember:

Covering those will give you a high level of confidence that your persistence logic is working correctly. You will have unit tests for the individual components, like mappers, and integration tests to make sure the whole abstraction built for persistence is working correctly.

And by following the ideas and principles described in this post, like custom matchers, your tests will stay nice and clean.


A working example can be found here.

About Novoda

We plan, design, and develop the world’s most desirable Android products. Our team’s expertise helps brands like Sony, Motorola, Tesco, Channel4, BBC, and News Corp build fully customized Android devices or simply make their mobile experiences the best on the market. Since 2008, our full in-house teams work from London, Liverpool, Berlin, Barcelona, and NYC.

Let’s get in contact