How to test Singletons and JCOs?

Background

Some time ago I manifested my dislike for using ETS tables because I saw them as Singletons, which bring a myriad of problems when testing. However @peerreynders linked me to the Just Create One pattern, spearheaded by our dear Uncle Bob.

Theoretical differences

I get the main difference. You can ask an app to create a Singleton a million times, and it will always return the same instance. With JCO, you can’t ask the app to create something a million times - you create it once and that’s it.

Practical differences

But in practice, specially when it comes to testing (which is what I am interested in), they are really both the same. You need a mock (mock as a noun) for each one, and once you bring either of them into your app, you basically forfeit concurrent tests because now you have a global state to worry about - which I dislike very much.

How do you test (or perhaps avoid?) something like a Singleton or a JCO instance and still allow for concurrent tests?

My only idea is to do functional injection, but then I have to pass a (huge) list of dependencies to every function. It is rather discouraging from a design perspective, as you literally need to pass around the whole jungle so you can tell a Gorilla to eat his freaking banana.

Discussion time

Any ideas on how to fix this, testing wise?

So there are a few considerations: Is the singleton stateful? Can it be mocked for the whole testsuite at once or do you need mocks per single test.

If the latter and it’s stateful then you’re probably out of luck. Even things like Ecto.Sandbox are quite powerful workarounds at best (without explicit ownership passing / injection of dependencies).

But I’m wondering why ETS brought this to the table, as ETS has other usecases as well. Named ETS tables with direct access are singletons, but that’s not all ETS can do.

ETS and Registry are just 2 examples of Singletons (or JCOs) that are widely used in Elixir apps (as well as in my projects as well).

I am aware they may have other uses, but 90% we use them to store state as a singleton. This is the part I want to isolate and test … And if I am really out of luck, perhaps I should consider alternatives? ( the only alternative coming to mind is to save state in a GenServer process and test the inter-process communcation’s protocol but then I would be spawning processes for the sake of testing without any real runtime benefits and I don’t like that either).

I’m not sure how a GenServer would make it less of a singleton though? As I said it’s not really constraint to ETS. The best way around is imho not having a singleton in the first place, like with explicit dependency injection. Or look into some fancy workaround like Ecto.Sandbox does for Tasks in the newest version, which is basically an implicit version of ownership injection.

With a GenServer you would not test the state itself, you would test the interactions with it. That’s the charm of interprocess protocol testing - you test if a given command was issued and that’s it.

The drawback here however, is that using GenServers has other types of drawbacks when compared to ETS tables (a discussion for another time).

You should be able to do the same with ETS though, if you hide it behind a functional interface and not use :ets directly all over the place.

The problem is that with ETS (or Registry) you have to inject them directly into the functions that use them, thus we arrive at the whole jungle, gorilla and banana dilema.

I don’t really see how a GenServer would work differently here. Can you elaborate on that?

I don’t agree with this part. The idea behind Just Create One is to replace a global compile time dependency with a local one that is injected at runtime.

If you have 20 tests you can create:

  • 20 distinct ETS tables with distinct handles
  • 20 distinct ETS tables with distinct names
  • 20 distinct GenServers with distinct pids
  • 20 distinct GenServers with distinct names

i.e. one for each test so that you can run the tests concurrently.

That of course means that the process under test has to have the reference (handle/pid/name) injected during initialization and the reference has to be held in the process state. By extension any code using the reference will have it passed from the process state.

but then I have to pass a (huge) list of dependencies to every function.

how big are your functions that they need a (huge) list of dependencies??? This may be symptomatic of an entirely different organizational problem.

2 Likes

I would really like some tutorials or information about this. Testing code that uses ETS tables (or anything that uses them like Registry) is the bane of my existence in the company, as it makes unit tests quite hard to deal with and I hadn’t come up with a decoupling strategy that works good enough yet.

If you have 3 low level functions with side effects (HTTP request, DB operation, etc) and you have a function that is a composition of those 3, then in order to run the latter function you need to inject the dependencies of its pipeline.

Now imagine you combine this function with yet another function … The number of dependencies grows without bound.

You can say something among the lines of “perhaps you are testing the implementation instead of the contract”, but if I am only testing the functions those module’s APIs make public then I know this is not what is happening.

Another issue here is that for a system big enough, testing their public APIs is a nightmare because the input and the outputs generated are so big, that no one really understands what is going on and they end up just copy pasting whatever the system spit and use it as a test…

But I digress… my focus here is finding a good strategy that allows me to test code that uses ETS tables and Registries in an isolated concurrent and fast way.

Not really a tutorial, but still worth watching: https://www.destroyallsoftware.com/talks/boundaries

To me that is what this is about:
https://elixir-lang.org/getting-started/mix-otp/ets.html#ets-as-a-cache

Since each test has a unique name, we use the test name to name our registries. This way, we no longer need to pass the registry PID around, instead we identify it by the test name.