[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]