[UPBEAT MUSIC]
SIMONA STOJANOVIC: Hi there.
Welcome back to the Mad Skills series on Jetpack DataStore.
In this fifth episode, we will be covering
two additional concepts--
DataStore-to-DataStore migrations and testing.
Hopefully, this will provide you with all the information
you need to add DataStore to your app successfully.
Let's jump into Android Studio straightaway to take a look
at these concepts in code.
We previously talked about migrating
from SharedPreferences to DataStore
using the SharedPreferences migration.
However, it is likely that at some point
you will also need to do a DataStore-to-DataStore
migration-- for example, when you make
some significant changes to your data set,
like renaming your data model values or changing their type.
This process is very similar to the migration
from SharedPreferences.
In fact, SharedPreferences migration
is just an implementation of the Data Migration Interface.
To understand how to properly use Data Migration,
let's break this down.
Data Migration is the interface for migrations to DataStore.
Methods on this migration-- shouldMigrate, migrate,
and cleanup--
may be called multiple times if DataStore encounters issues
when writing the newly-migrated data to this,
or if any of the migrations throws an exception.
shouldMigrate specifies whether this migration
needs to be performed.
If this returns false, no migration or cleanup
will occur.
This will be called every time the DataStore is initialized.
Migrate performs the migration.
If it fails, DataStore will not commit any data to disk,
cleanup will not be called, and the exception
will be propagated back to the DataStore call that
triggered the migration.
Future calls of DataStore will result in Data Migrations being
attempted again.
Note that migrate will always be called before a call
to cleanup.
Cleanup is the place where you will implement any logic
to remove the data from the old storage.
This will not be called if the migration fails.
If cleanup throws an exception, the exception
would be propagated back to the DataStore call that
triggered the migration, and future calls to DataStore
will result in Data Migrations being attempted again.
We will work off the Working with Preferences DataStore
codelab.
In order to create an implementation that
would suit our migration needs, let's imagine a scenario
where we'd want to migrate from Old Preferences DataStore
to New Preferences DataStore.
We want to remap and rename one specific old float key value
pair to a new INT one.
Migrate all other key value pairs
from old to new as they are.
Clean up old storage.
For simplicity, we will do all this
in our activity using the preferences DataStore
delegates.
But you can follow the instructions
from our previous episode on Hilt injection
to move this into an injection module.
We implement Data Migration and override its functions
in the following manner.
Old Preferences DataStore is our old data store
we want to migrate from.
New Preferences DataStore is the new storage, which
we want to migrate our data to.
Therefore, in produceMigrations, we implement the Data Migration
Interface and override cleanup to remove the data
from the old storage, migrate, which
provides an instruction on how exactly
the old data is to be transformed into the new data,
and shouldMigrate, representing the condition
for whether the migration should happen.
You can specify any condition here
that meets your Data Migration requirements.
For example, initiate the migration
only if the old data storage contains values and hasn't
been cleared yet.
ProduceMigrations will ensure that the migrations
are run before any potential data access
to the new data store.
This means your migration must have succeeded before DataStore
emits any further values and before it begins making
any new changes to the data.
As the produceMigrations parameter
takes in a list of data migrations,
you could transfer from as many old storages as you'd like.
That is all it takes to migrate your data safely.
You can easily follow the same pattern
to migrate proto to proto DataStore.
The only difference would be in how you transform the data.
Every good story needs good testing.
To wrap up our series, we will go over
how to approach testing your DataStore.
Again, we'll be referring to the Working
with Preferences DataStore Codelab as a starting point.
However, keep in mind you can use this material for setting
up proto DataStore testing, as it would be very similar.
To test DataStore fully, we'll need to do a bit of setup
for instrumentation testing.
This would enable us to verify that real updates are being
made to our storage according to our expectations,
as we'd be writing and reading from an actual file.
It is also possible to just mock your DataStore instance
and inject it as a dependency to another class when
unit testing, but you wouldn't be
able to run any real verification
checks on the DataStore itself.
In our code, the class that is responsible for reading
and writing data to our DataStore
is the UserPreferencesRepository.
So we'd want to verify that this class, along with its functions
that interact with DataStore, are working as expected.
We create a UserPreferencesRepository test
class.
Now that we have the scaffolding for our test,
let's start with creating our test subject,
UserPreferencesRepository, and work our way backwards.
To get an instance of UserPreferencesRepository,
we need to pass an instance of DataStore.
We'll build a quick test DataStore instance
that will create a separate test file which we can further
use for setting and verifying dummy data.
We use the Preferences DataStore factory to create a Preferences
DataStore instance.
And we pass testCoroutineScope, which our test operations will
be performed in, produceFile that constructs
a test file we will use for reading and writing
data using the test context.
Since DataStore is based on Kotlin coroutines,
we need to make sure our test has the right coroutine
setup in place.
To do that, we need to add the testCoroutineDispatcher.
This is a coroutine dispatcher that
performs execution of coroutines,
which is by default immediate.
That means any tasks scheduled to be run without delay
are immediately executed.
TestCoroutineScope is a scope which
provides detailed control over the execution of coroutines
for tests.
The job addition allows us to easily cancel the coroutine
as part of a regular cleanup after each test.
After setting up all that, our test class
should now look like this.
Our first test will be just verifying the state of our data
when the test DataStore is created.
We'll get the snapshot of the data
without subscribing to the flow and do
a quick check against our expected result.
In our UserPreferencesRepository,
FetchInitialPreferences is the first function
we'd like to test.
When the repository is first created,
it checks if the DataStore is empty
and sets a default instance of our data in that case.
If we think about the necessary steps for this test case,
we would need to create a test DataStore with default
value stored, which we've done, create the test subject,
UserPreferencesRepository.
Also done.
Set the expected user preferences
representing what we're expecting
to find in the test DataStore.
We haven't done this yet.
Call the repository, fetch initial preferences.
Also not done yet.
Verify the return values match our expected result. Not done.
Following these steps, our test should look like this.
You might also notice Android Studio complaining there
about how suspend function fetchInitialPreferences should
be called only from a coroutine or another suspend function.
And indeed, our fetchInitialPreferences
is a suspend function.
But since we're calling it from a test,
we need to make sure we avoid any real-time delays.
Kotlin coroutine tests saves the day yet again
with a simple solution for testing.
Surround it with testCoroutineSco
pe.runBlockingTest.
One down, one to go.
Our next test case verifies if our repository
is making the right changes to DataStore
by enabling or disabling the store sort order value.
We follow a similar pattern.
Create a test DataStore with default values stored,
which we've done.
Create a test subject, UserPreferencesRepository.
Also done.
Call the repository enableSortByDeadLine
with a new value, which we haven't done yet.
Verify the test DataStore values coming
from repositories userPreferencesFlow
match our expected result. Not done yet.
Therefore, in our test, we would then
add a call to enable sortByDeadLine
to our repository with the new value
and then verify that it has been successfully written
to DataStore.
That covers our basic test cases.
You can follow the same steps to increase your test
coverage for this repository class
and test its other functions.
Now all that's left to do is a bit of maintenance and cleanup
for a healthy testing environment, and that's it.
Now we've covered how to test Preferences DataStore.
You can follow the same pattern and try it out
on Proto DataStore as well.
In this Mad Skills series on Jetpack DataStore,
we have covered a lot of different topics,
but all of which are crucial for understanding
DataStore and using it correctly in a production environment.
We've looked at how DataStore works,
what benefits it brings over SharedPreferences,
and how to add it to your app.
We also discussed which serialization approach
you can use, how to inject it with Hilt,
and finally, how to test it.
Now it's up to you to try it out.
[UPBEAT MUSIC]