[MUSIC PLAYING]
ERIC CHANG: Hi, everyone, and welcome to the Hilt testing
episode of the "MAD" series.
This episode will teach you how to write
tests using Hilt and some of the best
practices you might want to follow to make testing easier.
But before we jump into the Hilt testing APIs,
since Hilt is a more opinionated framework,
we're going to start by discussing
the overall philosophy and approach
that Hilt takes with testing.
Then we'll discuss how to write tests in Hilt,
and finally go through some best practices and trade-offs.
So jumping right in, one of the core goals of Hilt
is to help you avoid having to create unnecessary fakes
and mocks in your tests so you can use real objects instead.
Let's talk about why we would want to do that.
In general, testing with real objects
will give you better tests with more test coverage.
You usually only want to fake or mock something
when there's something conceptually expensive or too
heavy to do in the test.
For example, you'll usually want to stub out calls
to a backend server.
But for a simple helper object that
just holds state in memory, you'll
want to use the real thing.
The problem with mocks is that they're often
used to cover up other problems, where
there isn't anything that needs to conceptually
be replaced in a test.
In fact, Dagger without Hilt often
had some of these problems.
This is because Dagger tends to be
difficult and cumbersome to setup in tests, which
meant many tests relied on manual instantiation
as an alternative.
Let's take a closer look at manual instantiation
to see how it leads to an overuse of mocks.
So in this example, we have an EventManager class
that we normally would let Dagger create for us,
since it has an @Inject constructor.
In the test, though, because we don't want to setup Dagger,
we instead just call the constructor manually ourselves.
This seems fine at first, because we're just
calling the constructor like Dagger would, right?
Well, let's see where this runs into problems.
You might have noticed before that I
didn't show where that dataModel and errorHandler came from.
Well, that's easy enough.
We can similarly call their constructors, too.
But now we can see the problem.
What if they also have dependencies?
We could end up calling a lot of constructors
here just to create our EventManager class,
and we haven't even gotten to the actual test.
Also, all of the constructor calls
are going to make our test very brittle.
Any change to any of the constructors
will break these tests, even in cases
that wouldn't have broken the production code
or changed any behavior.
For example, just reordering our constructor arguments,
a NOOP in Dagger, is going to break all these tests.
So instead of doing that, that's where
people tend to just give up and decide to mock that data model
and error handler so we don't fill our test up
with all these constructors.
But now we ended up using mocks here,
not because the dataModel and errorHandler
classes are doing anything too expensive for our test.
We used mocks because the alternative
is just unmaintainable.
And that's not really a good reason to give up
on all that test coverage, and we likely still
have a test that is going to be difficult to maintain.
So can we avoid all that?
Well, let's take a look at how Hilt approaches testing.
Hilt lets you use Dagger in your tests
without the extra boilerplate of setting up Dagger.
Normally, you'd have to create a whole new set of Dagger
components for your tests if you had some bindings
you wanted to replace.
But Hilt lets you do those replacements without doing
all of that setup.
And by using Dagger, we get to avoid
all the pitfalls of manually instantiating
our objects that we just saw.
So there's just a few simple things
you need to do to setup Hilt in your tests.
First, you'll need to annotate your tests
with @HiltAndroidTest.
This is the bootstrap point for Hilt, just like HiltAndroidApp.
Then you'll also need to include our Hilt Android test rule.
This helps Hilt get hooked into the test lifecycle.
Finally, you'll also need to setup the test application
to use our HiltTestApplication class.
I don't show that here, since that setup will be different
depending on if it's a Roboelectric or instrumentation
test.
And there's a few choices you can make on how to do that.
But you can find those instructions
on the dagger.dev site.
Now, after you get that setup, you're
free to start injecting objects you
need by adding @Inject fields.
These objects get injected when you call inject on our test
rule, so you'll generally want to do
that in your setup method.
One thing to be aware of is that injected objects
have to come from the singleton component.
If you need something from the activity component or fragment
component, you'll need to use the regular Android Testing
APIs to create an activity or fragment
and grab dependencies off of them.
But once your objects are injected,
then you can just write your test.
You don't have to worry about any
of the setup for this object, because Hilt and Dagger will
create them, just like in production,
based off the bindings and modules you have defined.
Now let's say you actually have a dependency you
need to replace in your test.
Like our DataFetcher here probably
makes a server call that we want to fake out.
This is where @TestInstallIn comes in.
In Hilt, you can't replace bindings directly,
but you can replace the modules that hold them.
In this example, we've created a FakeBackendModule
that replaces our production backend module in tests.
Just like a regular InstallIn module,
we set the component that the @TestInstallIn module
should be installed in.
But we can also tell it what production
module it's going to replace.
This effectively removes that production module
from the test.
And just like a regular module, we
can add bindings to our FakeBackendModule
to replace the production bindings.
One thing to keep in mind is that @TestInstallIn modules
are found via your dependencies, just like InstallIn modules.
So that means they'll apply to all the tests in the Gradle
module.
And that's it.
Now we've replaced our server call in our test with our fake.
Now there might be situations where you only
want to do a change for one particular test,
instead of multiple.
This is where @UninstallModules come in.
Unlike @TestInstallIn, @UninstallModules
is placed on the test where you want it to apply.
In the annotation, you can specify
which modules you want to remove from your test.
Since it isn't defined on a module,
there isn't a specific place to put the replacement bindings.
So in your test, you can choose how
you want to add replacement bindings yourself.
Here, we use BindValue to add in our fake.
But you can also define a module nested inside your test class
if you want.
That's up to you.
So you might be wondering, which of these should you use?
And the answer will really depend
on your particular setup, though we generally recommend
starting with @TestInstallIn.
@TestInstallIn is good for making replacements
across all of your tests in a build unit and makes for easier
configuration.
But on the flip side, it can mean less flexibility
than @UninstallModules, where you
can craft a particular binding setup per test.
@TestInstallIn is better for build speed, though,
which is why we recommend starting with it.
To understand why @TestInstallIn is better for build speed,
let's look at what Hilt needs to build for your tests.
Here, we have three different tests
that use three different sets of modules.
These sets are different, because tests 2 and 3 use
@UninstallModules to replace different modules,
while test 1 uses the global defaults, which includes
@TestInstallIn modules.
Because the modules are different for each test,
Hilt has to create a whole separate set of Dagger
components for each one.
Building Dagger components can be pretty expensive, though,
because they often have a lot of code that needs to be compiled.
So these extra components can represent a pretty good chunk
of the build time.
If we can change test 3 to use the global defaults like test
1, maybe module B can be replaced in every test
via a @TestInstallIn module.
Or maybe we can change the test around
to not need the replacement.
Then test 1 and test 3 can actually
share the same set of Dagger components.
This can shave a lot of time off of our build.
Now this doesn't mean that @UninstallModules should never
be used.
Sometimes, you do need a different setup for your test.
Just be aware of the trade-offs.
Also, when addressing build speed,
there's another factor that you can optimize.
This is the vertical axis of how many modules and entry points
are pulled into your test.
A lot of the time, this might be your entire app,
when in reality, your tests may only
be exercised in a small portion of your features.
Since Hilt doesn't know which modules and entry points are
needed, we have to use everything we can find,
which can end up bloating your build.
One thing you can do is to try to avoid extra module and entry
point dependencies in your tests that aren't necessary.
When you trim down these dependencies,
then the generated Dagger components
for your tests become smaller, which
should make the builds for these tests a lot faster.
One way you can do this is to organize your Gradle modules so
that a lot of your tests are not in the main app Gradle module,
but are instead in separate library Gradle
modules to reduce dependencies.
If you can trim down your dependencies enough,
then you might find that adding extra sets of test components
with @UninstallModules doesn't cost that much anymore,
and it may give you more flexibility in your test
configurations without paying as much in build speed.
In the end, though, evaluating this trade-off and which
tool you want to use is going to very much depend on
how your app, tests, and build system are set, up today.
One last thing to keep in mind that will also
help you write tests is to consider how
you organize your Hilt modules.
Your code base might have ended up
with very large Dagger modules that have a lot of bindings.
But with Hilt, having large modules that do a lot of things
may make testing harder, because you
have to replace whole modules, not individual bindings.
When making modules in Hilt, try to keep them
to a single purpose, maybe even with just one public binding.
This helps readability and makes it easier
to replace them in tests, if needed.
OK.
So to quickly summarize, we talked about Hilt's testing
philosophy of using real objects and avoiding overuse of mocks.
Then we went through the basic APIs
of writing a test using Hilt and looked
at how to replace modules using @TestInstallIn
and @UninstallModules.
Finally, we discussed the different trade-offs
between those module replacement options.
If you want more information on testing with Hilt,
visit these sites here to read up
on the latest information and guides.
Thanks for listening to me today,
and keep an eye out for more "MAD Skills" episodes.
Bye.
[MUSIC PLAYING]