12 Comments
User's avatar
Jelena Cupac's avatar

How do you typically guide developers out of the "one test per class" mindset?

Expand full comment
Valentina Jemuović's avatar

It’s a very big challenge, because many developers have the ingrained mindset that unit testing *must* mean one unit test file per source class. It’s not surprising, since that’s what most tutorials show on the internet, and even IDEs have shortcuts for this, and it’s what they’ve seen other colleagues do.

That is why I like to propose to teams to do a “thought experiment” whereby we compare and contrast the “one test per class“ versus “one test per behavior“ approaches, on an actual example, and then I simulate some refactorings (e.g. splitting that class into two classes, applying design patterns, anything that causes changes to UML class structure diagram) and then asking developers to compare which approach causes higher maintenance cost. That’s when they see that “one test per class“ is more expensive, that it leads to wasted time in reworking the test, that it makes the test less reliable (because the test isn’t stable whilst we’re refactoring), and that the test even hinders refactoring (because we think - “Oh no, this will break so many tests, I might as well not do this refactoring“… or deciding to just delete/disable the tests).

Expand full comment
Jacek Andrzejewski's avatar

When dependencies are just pure logic I use real ones.

When they do some effects like database queries I have parameters for A with potential results of BCD. Then only when B result is missing it is called.

This way I can kind of mock it, without using flaky mocking.

Example code

```

class SchedulePlanner {

public function plan(Datetime date, ?Employee[] employees = null, ?Shift[] = null) {

employees ??= self::getEmployees(date);

shifts ??= self::getShifts(date);

(...)

}

}

```

And then in test I can just pass the results I'd want.

```

planner = new SchedulePlanner(date);

//In real test customize employees and shifts

employees = EmployeeFactory::create(2);

shifts = ShiftFactory::create(2);

plan = planner->plan(date,employees, shifts);

```

If I were to describe it shortly then I'd say it's functional approach bolted onto OOP.

I don't know if it's very good, but for me it has worked so far.

Expand full comment
Valentina Jemuović's avatar

Is the approach you use:

- Functional Core: e.g. SchedulePlanner, it just has business logic, it has no dependency on repository/gateway (hence zero mocking in testing).... this is purely functional, hence we just test outputs...

- Imperative Shell: e.g. a class ReservationService, a lightweight class that has dependency on SchedulePlanner and has dependency on ReservationRepository... this has side-effects, so when we use test doubles we verify the side effects...

Expand full comment
Jacek Andrzejewski's avatar

Almost that. I'm working on a legacy system most of the time so I try to move towards that. But it's not always viable so I at least try to make it bit more modular.

In my case SchedulePlanner is purely functional when you pass needed data. If not it has private methods that do depend on repositories and so on. So then it's not pure.

I guess it's a middle step between everything being mixed up and the functional core imperative shell.

Expand full comment
Valentina Jemuović's avatar

Given legacy code, what are the steps you've taken to move from everything mixed up (initial state) and moving to functional core (end state)?

Expand full comment
Jacek Andrzejewski's avatar

That's a good question.

Firstly I have to work in time constraints and can't really spend as much time as I'd like on cleaning the code up.

So I don't touch things if I don't need to (usually).

For new code I'm writing I'm obviously doing it in line with functional core and imperative shell. Also trying to do DDD or Hexagonal architecture (depends on project) and keeping legacy as it's own thing.

For existing code that I do have to change I try to first pull out anything that does side effects into it's own method, helper, service, anything. So any API calls, DB updates, file saving and so on.

Then I try to move that layer up as a dependency that is passed through optional argument. If nothing is passed the real version is called.

This leaves me with a messy but pure function that can be easily tested.

Then I cover that (now) pure function with tests to pinpoint the behaviour, especially the side effects it calls by using spies.

Further I usually leave it as is, and then over time try to make things more consistent, better grouped up and less duplicated.

Expand full comment
Valentina Jemuović's avatar

That's a good approach, to *not* spend too much time on cleaning up code, and touching the least we need to touch. We could think of it as an optimization activity - given we need to implement some change, what's the minimal change we need to do to make the change testable, before implementing the change.

The approach you described provides with assurance that the pure function works correctly, i.e. that we don't introduce bugs into the pure function per se.

How would you handle possible bugs that occurred as you were breaking the functional core from imperative shell... e.g. suppose that as you were pulling out anything that does side effects up, imagine you were suppose to do DB updates, or supposed to call the pure function but accidentally forget to put that line in your imperative shell... I know it would be a "silly" mistake, but interested to hear how you would handle it?

Expand full comment
Jacek Andrzejewski's avatar

Another way to explain it: looking at last image you'll see that you test only the class accessible from outside the module.

So tests should only do things that production code would do.

Of course you can have tests for internal classes inaccessible from outside. But it's rather a function test or class test, not unit test. You could call it a logic test maybe.

Expand full comment
Valentina Jemuović's avatar

Yes, that's the intention - the module has a public API (it might be some public class/interface) and it has "internals" (internal classes). The public API expresses behavior.

"Of course you can have tests for internal classes inaccessible from outside." -> in your experience, what are cases when you have such tests, what are examples of situations?

Expand full comment
Jacek Andrzejewski's avatar

I use this type of internal tests when I want to be sure of some invariants that are hard to test from outside and yet future code might depend on them. It guides the internal design.

And another case is when I have independent behaviors that will multiply test cases when tested from outside.

For example if I have method A that uses B, C, D, and each of B, C, D have possible 10 test cases then you need 10*10*10 cases to test them through A, yet only 10+10+10 to test directly.

I use that mostly for private static dependencies that are in essence local helpers.

Example: class that generates a schedule for employees based on their preferences, shifts, and multiple other requirements.

I would have a static private helper method to get all employees that need schedule. I'd have tests for it specifically for invariants like when I add an inactive employee to db then result doesn't change.

Or when I change employee preference to a day he got assigned to and recalculate schedule then result shouldn't change.

This type of tests is most helpful during implementation of the small parts, later they might not have that much value. But when implementing they help pinpoint mistakes.

Later when refactoring with changes to structure you'd most probably delete them. But as long as structure is the same they are helpful.

Obviously not all languages would let you do that nicely when it's private.

Expand full comment
Valentina Jemuović's avatar

"And another case is when I have independent behaviors that will multiply test cases when tested from outside.

For example if I have method A that uses B, C, D, and each of B, C, D have possible 10 test cases then you need 10*10*10 cases to test them through A, yet only 10+10+10 to test directly."

In that case,

- Yes, clear you would directly test B, C, D 10+10+10 (to avoid the combinatorial explosion 10*10*10)

- But what about A, what type of test would you run on A? Would it be targeting A and mocking out B, C, D or would it be targeting A and spanning real B, C, D

Expand full comment