14 Comments
User's avatar
Jelena Cupac's avatar

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

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).

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.

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...

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.

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)?

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.

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.

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?

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.

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