Article

The Value of Unit Tests in Mobile Development

June 24, 2024

By Robert Hartman | Principal Software Engineer             

Having a mobile app codebase that is heavily unit-tested is such a benefit: as a professional software developer, if you’re not writing copious unit tests, you’re missing out. Writing tests can be frustrating, and they can and do add a lot of extra work, but writing tests will make your code better and make you a better developer. It’s just hard to see that earlier in your career – at least it was for me.

You might think you can skip the heavy unit testing because when you add a new feature or set of screens to your app, you probably manually test the heck out of it. Surely you run through all the happy path scenarios and make sure things work behind the scenes and look right on the screen. You might even temporarily tweak some code here and there to get into some error cases to make sure things work as expected. That’s all fine and good, and I do it all the time. But what about months from now when you (or more importantly someone else) needs to change some related code; will you want (let alone remember) to do all those manual tests and temporarily change the code to get into error cases? Or will you do a quick sanity check and sort of gloss over the more thorough testing? I’m definitely guilty of the latter.

Writing good software almost always involves going back and improving what you or someone else wrote, so-called “refactoring”. Take a little time away from your code, and the next time you look at it, that which looked polished and perfect now seems less than ideal. Sometimes these refactoring efforts can have far reaching impacts in your codebase (it happens!). Have you ever had an uneasy, anxious feeling after refactoring, or even after simply renaming a variable or function? The little voice in your head nags at you, “I hope I didn’t break anything…” So, being conscientious, you spend a bunch of time staring at the diff and analyzing all the code paths to make sure your changes are good. This analysis can be hard work, and if you really care about what you’re doing, it can be stressful and anxiety inducing. Or worse, you may decide not to perform the refactoring because you feel it’s too risky. At that point entropy is alive and well in the codebase – not a good situation.

Having experienced both ends of the spectrum, that is, working on code that has little to no unit tests that I am afraid to change on one hand, and code that has 100% test coverage on the other, I can say with absolute certainty that the refactor scenario presented above goes a whole lot better when you have unit tests to support you. It’s a satisfying, dare I say smug feeling knowing you can refactor with confidence because you have a rich suite of tests poking and prodding at every line of your code – or at least most of them. Another thing is efficiency. Writing tests is indeed more work up front, but once tests are written, you’ll always have them, and executing them is easy: you just kick them off and let the computer do all the work.

While I won’t get into the nitty gritty of unit testing here, nor cover all of its benefits (there are plenty of materials out there produced by people more qualified than me), I did want to mention a few other things that stand out to me.

It’s a pretty common tendency for developers to create rather large, complex functions. Obviously, the larger and more complex a function is, the harder it is to understand and logically validate. But just as importantly, such functions are hard to test; these tests end up being a lot larger and more complex than the function itself.

Unless you’re doing Test Driven Development, it’s very common to initially write out an algorithm as one of these large functions, just to work out the logic and simply get the code working. But once the logic is solid, you’re not really done. Once you start to write tests for such code, you’ll quickly run up against the fact that the test is too large and cumbersome. So naturally you’ll go back and figure out how to break the function up into smaller units of work. Even if the code that gets factored out is only used in one place, this refactoring has huge benefits: It _could_ be reused if necessary, it’s much easier to understand and verify the logic in small chunks, descriptive and self-documenting function names can be created for each unit of work, and voila you’ve got much better, maintainable code. As you get accustomed to testing everything, that refactoring process begins happening sooner. Requiring unit tests heavily impacts how you write your code.

Mobile development can present unique challenges with unit testing. Most of my mobile work has been in iOS with a fair bit of Android. I’ve worked on a few React Native projects, and lately at SpinDance we’re starting to do a lot of Flutter work. A common problem in iOS, especially in UIKit apps, is MVC: the “massive view controller” problem. This also shows up in Android as large fragments or activities. Let’s say you’ve coded yourself into an MVC fix in a UIKit app. You want to unit test that code, but its dependency on the view makes unit testing inconvenient. This is a great opportunity to slim down your view controller by refactoring the business logic, input/output data transformations, etc., into smaller units that can be easily tested on their own.

A lot of the mobile work we do at SpinDance involves communicating with connected devices that very often use proprietary data formats in their device communications. Code that serializes and deserializes such data is a prime candidate for unit tests. My usual strategy is, after reading through the spec for the communication protocol and taking a first shot at implementing the code that parses the data, I’ll either capture the raw data from the device somehow or ask the embedded developer to send me a capture, and then simply build a unit test around deserializing and/or serializing the captured data. It then becomes easy to change a bit or byte here or there in the data to also test the deserialization failure scenarios.

Following a strict unit testing practice usually means that all dependencies of the code under test must be mocked. Mocking is where you “fake out” the code that’s getting called by the code under test. That is, you “mock” the dependencies of the code under test by replacing those dependencies with test-specific objects that act in a way that allows the code under test to follow a specific execution path, and then you can potentially verify the mocks were interacted with correctly at the end of the test. Think of it this way, if the code under test calls some function or accesses some external data, a strict unit testing approach requires that the function and external data must be mocked somehow. The goal of the test is not to verify that the called function does the right thing or that the external data is correct, the test should only validate that the code under test utilizes the called function and external data correctly. The called function and the source of the external data would then need to have their own, separate tests.

Now that’s not to say you can’t do things a little differently. It’s possible to write quasi unit tests that cover the integration of software components. Let’s say the code under test calls modules A and B, and module B calls module C, which calls into the mobile OS Bluetooth library. Another way of saying that is the code under test depends upon A and B, B depends upon C, and C depends upon Bluetooth. You could get a lot of “bang for the buck” by only mocking C’s interactions with the Bluetooth library. It might be difficult to set up all dependent modules to be in the right state in order for the test to work, and it might not be possible to test certain corner cases and failure scenarios in the call chain, but there are times when tests like these are effective and not hard to write, and you end up getting a lot of test coverage for not much test code. The downside is the missed code coverage for the corner cases and failure scenarios and the fact that if tests start failing, it becomes harder to find the source of the failure. But these kinds of tests, while not as pure as strict unit tests, have a place.

In mobile app development, some languages and platforms make mocking easier than others. React Native has Jest, Android has Mockito, and both are powerful and seem to allow you to do just about anything with your mocks. However for iOS and Flutter apps, things aren’t quite as easy. Sure, Flutter has Mockito as well, but it doesn’t allow you to mock class or static methods. iOS Objective-C has OCMock… but, well, now we have Swift. And to my knowledge, Swift has no officially supported mocking framework. While a few options do exist, I’m not sure how well supported they are, and I haven’t used them. So, Swift and Dart/Flutter can present special difficulties when writing unit tests. In a future article, I’ll go into more detail about how to deal with those issues. Specifically, think of the scenario above where module C depended upon the Bluetooth library. That type of dependency is often a sticking point for unit tests, and one I’ll cover next.

As I mentioned, the goal of this article was not to cover every aspect of unit testing, just to help convince a mobile developer who’s on the fence to take a step in the right direction… I know you’re out there. Why not give unit testing more of your attention? You’ll be glad you did.