Android w/o DI — Part III

Testing & Troubleshooting

Bob Dahlberg
The Startup
5 min readJan 28, 2021

--

The last part of the series is about testing and troubleshooting. And here is where I’ve drawn the most inspiration from event sourcing.

In the first part of the series, I mentioned that I have a LoggingActor that intercepts every event sent on the stream and puts a message on the log. I also send the log message to Firebase Crashlytics to get a narrative up to a potential crash.

Let’s first have a look at an example narrative to get a better understanding of the benefits it gives. For the simple example of a user opening and logging in to our application, it may look like this:

1. >> UserStatusEvent()
2. << UserState(result=Ok(status=NO_USER, user=null))
3. >> LoginEvent(username=bob, password=***)
4. << AuthState(result=Err(Failure(Connection failed /192.168.1.1)))
5. << UserState(result=Err(Failure(Connection failed /192.168.1.1)))

On line 1, we send an event to see the current user status, and on line 2, we get the current state back. Then the user fills the login form and hits the login-button, which generates the event on line 3. That event is captured by the AuthActor that tries to log the user in. Then sends the result back as an AuthState on line 4. That state isn’t propagated to the ViewModel layer but is captured by the UserActor dispatching a new UserState (line 5) that the ViewModel reacts to and displays a proper login-error message to the user.

The walkthrough above was probably not even needed to grasp the big picture of what happened. The narrative of events speaks for itself. And it’s straightforward to figure out where it went wrong and where to start troubleshooting.

Troubleshooting

Now imagine if you have crash reports on Crashlytics, with this information attached to the crash. You will have the 50 latest events passing through the stream up to the crash. And I’d say that it would be more than plenty enough to see a connection between the crash and the state the app is in.

A reminder though, when logging the events like this, make sure to strip out all personal and sensitive data. I have a function in my base Event class, toLogString(), that I use for this remote logging, and if not implemented it defaults to only the event name. This way I can use the regular toString() to benefit from data classes when running locally and get all information about the events. Those logs are also stripped away during release builds.

Since this architecture is based on actors, I would argue that these logging behaviors should be split into two actors, one for the console and one for Firebase. The reason is that the user should be able to opt-out of the analytics/part. And this works wonders with actors. If the user opts-in, the actor is created and started. If the user opts out, the actor is stopped and garbage collected.

Testing

When it comes to testing an actor, it’s straightforward. You have one public function where you can pass in different events. And then you expect some state as the outcome. Depending on what I’m testing, I’m doing it in one out of two ways.

The first one is injecting a mock stream in the constructor of the actor. Then I can call the act function with an event and capture/verify the correct outcome in my mock.

The second one is more extensive and can help you with lots of scenarios. This is having a fake stream and then connect multiple actors to it that should react to each other. The most simple test case for this is adding two actors, send an event to one of them, and expecting a state from the other one. Like in the example narrative at the beginning of the article.

The more complex scenarios would be to emulate a chain of events with a lot of actors. Here the most tricky part would be to make sure that you trigger the events from the system/user from your test, and also that you might need to fake/alter/ignore some states sent back from your actors in the fake stream.

The latter one is not really a unit test per se but can be used as a smoke test, system integration test, or faulty behavior reproduction. Say you end up with a weird state in your app that you would like to avoid in the future. Then you can see the events leading up to it from the output, make a chain of events from that and have it replicated in a unit test, and from that, use a test-driven approach to solve that faulty behavior.

Simulations

To go even further with the architecture and the events as the source. We can actually start up our application in a specific state with code instead of having to click ourselves all the way to that state.

Like faking an incoming push notification, dropped wifi-connection, or similar by sending those events into our system. Since everything in our system should result in an event, we can simulate everything too. This is a true time-saver for hard to test integration-results.

To do this in a good way, I’ve ended up writing RunnerActors that has a queue of events to execute, logic for necessary delays, and rules to continue the chain of execution. And when all events are sent, it stops itself, and I can start my manual testing.

Combining all pieces, each time my app crashes, I have a source of events that I can put in a runner locally. And run both unit and manual testing on the same sequence that caused the crash. I’d say it’s hard to ask for more than that when troubleshooting an otherwise unknown behavior.

That was the last part of the event series, and I hope it has made you think about how you can improve your app's architecture with or without dependency injection, actors, events, etc. The more you read and expose yourself to different approaches, the better will the solution you chose be in the end.

The other articles in the series:

--

--

Bob Dahlberg
The Startup

Lead Developer at Qvik, Coach, Agile Thinker, GDG Lead.