Hilt testing best practices - MAD Skills

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[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]
Info
Channel: Android Developers
Views: 20,056
Rating: undefined out of 5
Keywords: purpose: Educate, pr_pr: Android, series: MAD Skills, type: Screencast (0-10min), GDS: Yes, introduction to hilt, hilt, what is hilt, how to use hilt, intro to hilt, hilt tutorial, hilt android, android hilt, mad skills, modern android development, developer, developers, android developer, android developers, google developers, android, google, Eric Chang
Id: oBpBWTb3k2g
Channel Id: undefined
Length: 10min 28sec (628 seconds)
Published: Mon Aug 30 2021
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.