DEV Community

Cover image for How the lack of internal state makes your classes easier to test and refactor
Marcin Wosinek for How to dev

Posted on • Originally published at how-to.dev

How the lack of internal state makes your classes easier to test and refactor

You likely often hear that test-driven development (TDD) or just writing tests can make your code better. It’s hard to say whether this is true unless you have seen the impact of writing unit tests on code before. Let’s take a look at this effect with a simple example: moving an internal state of a class to a dependency.

Lack of internal state and testability

The most straightforward way of keeping a state is to add a private variable and put aside the values you need for later use. This will do the job, but it makes testing more difficult. For complete test coverage, you would need to:

  1. Bring the instance to the state you want to test
  2. Check its behaviors

As you add more internal variables, achieving the expected state becomes exponentially more complicated: besides simple states, you will need to include combinations between them. There can be invalid combinations that are impossible to achieve if everything works as expected—but you could be interested in testing whether the code gracefully degrades if it reaches this impossible condition.

There is also a temptation to access the class’s private variables from the test, but this approach feels wrong. It depends on knowing the implementation of the class, and it ignores the interface we are defining for the type.

Moving the state out

To make the code more testable, we can define a separate class that will keep the state. This new class introduces a layer with a new interface surface that we can use to describe a relationship between objects. You can mock methods used to set and retrieve the state changes, making testing easier.

Low testability example

As an example with low testability, we will have an alarm clock class:

Image description

We can expect certain behaviors from this clock:

  • the clock rings when the current time matches the programmed alarm time
  • user can set the alarm time

If you wanted to test this behavior, you would need one of two approaches:

  1. wait until the hardcoded alarm time comes, and see if the alarm rings
  2. set the time just a few moments from the alarm time to see if it rings as expected

Approach 1 is wrong; it could require hours of waiting.

Approach 2 has downsides, too, they’re just more subtle. It would require your tests to read the current time and add seconds of waiting for results. Setting the wait time would be a trade-off between stable tests, where we wait long enough to avoid missing the point on slow machines, and waiting too long and slowing down the testing.

More testable example

We can make this code more testable by moving the state outside:

Image description

So, in this case, introduce a dependency— AlarmTimeStore—which keeps the value set outside the AlarmClock class.

How this makes testing easier

As we moved the state outside, we introduced a dependency to facilitate testing. When we run the class in the test, we replace the actual dependencies with mocks. Mocks are a drop-in replacement for other instances that provides the same interface but allows for setting expectations. You can provide a value that that function calls will return.

Mocking allows you to run the class or function in isolation from the other code—you can thus control what values are returned to your unit and check whether it’s behaving as expected.

Image description

How this makes code better

By moving the state outside, we define a clear separation between those two classes. As the persistence layer gets a clearly defined interface, it will be easier in the future to:

  • make it more advanced: for example, instead of storing the value in the runtime memory, save it to the browser or a file, or
  • reuse it in other parts of the application: as the application becomes more complicated, we can find different use cases that could be covered by generalizing a solution we build here.

Common critique: so many layers of abstraction

Some people criticize this approach for introducing too many layers of abstraction to implement features. I got feedback like this for the code I wrote when trying to keep it very testable. Most likely, it’s a matter of personal taste and beliefs about unit tests: I like my code to be covered by tests, and I’m happy with subtle changes to the code design to make sure it’s easily testable. If somebody doesn’t care about tests, I’m not sure if there are strong arguments to make that this approach is objectively better than the alternatives. The value of the flexibilities I’ve listed above depends on how likely we are to actually need them at some point. If it’s probable that you won’t need them, you could argue that it’s a premature act of preparation for a use case we might never need.

How do writing tests impact your code?

Are you writing tests regularly? Please share how it impacts your code—I would love to hear stories from you guys!

Top comments (2)

Collapse
 
lexlohr profile image
Alex Lohr

Since you should always test close to the actual use case, I dislike testing internal state in abstraction, even if I exclude it from the component - but only in case it gets complex.

Collapse
 
marcinwosinek profile image
Marcin Wosinek

Yeah, it's always balancing between keeping tests manageable and keeping them meaningful. I probably err a bit on the side of writing too much tests & too far away from how the code is actually working. I try to cover more realistic cases with End-to-End.