0

I realize that this question may seem to be a duplicate of questions such as this, this, this, this, and this. I'm specifically asking, however, how you would write unit tests using the Detroit style toward non-trivial code with multiple code paths. Other questions, articles, and explantations all discuss trivial examples such as a Calculator class. Further, I'm practicing CQS, or Command Query Separation, which alters the methods by which I write tests.

As per Martin Fowler's article "Mocks Aren't Stubs", I understand that there are two schools of thought toward TDD - Classical (Detroit) and Mockist (London).

When I first learned Unit Testing and TDD in general, I was taught the London style, utilizing Mocking Frameworks like Java's Mockito. I had no idea of the existence of Classical TDD.

The overutilization of Mocks in the London style concerns me in that tests are very much tied to implementation, making them brittle. Considering a lot of tests I've written have been behavioral in nature utilizing mocks, I'd like to learn and understand how you'd write tests using the Classical style.

To this effect, I have a few questions. For Classical testing,

  1. Should you use the real implementation of a given dependency or a fake class?
  2. Do Detroit practitioners have a different definition of what a "unit" is than Mockists do?

To further elaborate, here is a non-trivial real-world code example for signing up a user in a REST API.

public async signUpUser(userDTO: CreateUserDTO): Promise<void> {
    const validationResult = this.dataValidator.validate(UserValidators.createUser, userDTO);

    if (validationResult.isLeft()) 
        return Promise.reject(CommonErrors.ValidationError.create('User', validationResult.value)); 

    const [usernameTaken, emailTaken] = await Promise.all([
        this.userRepository.existsByUsername(userDTO.username),
        this.userRepository.existsByEmail(userDTO.email)
    ]) as [boolean, boolean];

    if (usernameTaken)
        return Promise.reject(CreateUserErrors.UsernameTakenError.create());

    if (emailTaken)
        return Promise.reject(CreateUserErrors.EmailTakenError.create());

    const hash = await this.authService.hashPassword(userDTO.password);

    const user: User = { id: 'create-an-id', ...userDTO, password: hash };

    await this.userRepository.addUser(user);

    this.emitter.emit('user-signed-up', user);
}

With my knowledge of the mocking approach, I'd generally mock every single dependency here, have mocks respond with certain results for given arguments, and then assert that the repository addUser method was called with the correct user.

Using the Classical approach to testing, I'd have a FakeUserRepository that operates on an in-memory collection and make assertions about the state of the Repository. The problem is, I'm not sure how dataValidator and authService fits in. Should they be real implementations that actually validate data and actually hash passwords? Or, should they be fakes too that honor their respective interfaces and return pre-programmed responses to certain inputs?

In other Service methods, there is an exception handler that throws certain exceptions based on exceptions thrown from the authService. How do you do state-based testing in that case? Do you need to build a Fake that honors the interface and that throws exceptions based on certain inputs? If so, aren't we basically back to creating mocks now?

To give you another example of the kind of function I'd be unsure how to build a fake for, see this JWT Token decoding method which is a part of my AuthenticationService:

public verifyAndDecodeAuthToken(
    candidateToken: string, 
    opts?: ITokenDecodingOptions
): Either<AuthorizationErrors.AuthorizationError, ITokenPayload> {
    try {
        return right(
            this.tokenHandler.verifyAndDecodeToken(candidateToken, 'my-secret', opts) as ITokenPayload
        );
    } catch (e) {
        switch (true) {
            case e instanceof TokenErrors.CouldNotDecodeTokenError:
                throw ApplicationErrors.UnexpectedError.create();
            case e instanceof TokenErrors.TokenExpiredError:
                return left(AuthorizationErrors.AuthorizationError.create());
            default:
                throw ApplicationErrors.UnexpectedError.create();
        }
    }
}

Here, you can see that the function can throw different errors which will have different meanings to the API caller. If I was building a fake here, the only thing I can think to do is have the fake respond with certain errors to hard-coded inputs, but again, this just feels like re-building the mocking framework now.

So, basically, at the end of the day, I'm unsure how you write unit tests without mocks using the Classical state-based assertion approach, and I'd appreciate any advice on how to do so for my code example above. Thanks.

  • 1
    Mock dependencies which makes tests slow(developers need quick feedback), such as (database, file system, web services etc.) and mock dependencies which are very very very complex to configure before the test. With other dependencies I would suggest to real implementations. Such approach provide possibility to change more code without rewriting tests. – Fabio Feb 10 '20 at 05:17
  • Thank you @Fabio. So would you suggest using the real authentication service and really hashing passwords, for example, or really generating JWTs? Would you, personally, still consider that to be a unit test? –  Feb 10 '20 at 06:08
  • I would still consider it as a test, which provide quick feedback during development. You can name it anything ;). I would use actual dependencies until using them start "hurting" me. I want to say, that there are no absolute answer, approach would always depend on the current context. You probably wouldn't test full logic of hashing, but only cases which affect behaviour of current class. – Fabio Feb 10 '20 at 06:56
  • "Would you [...] still consider that to be a unit test?" -> https://stackoverflow.com/a/56120843/5747415 – Dirk Herrmann Feb 12 '20 at 20:40

1 Answers1

3

Should you use the real implementation of a given dependency or a fake class?

As your own experience shows, overutilization of mocks makes tests brittle. Therefore, you should only use mocks (or other kinds of test doubles) if there is a reason to do so. Good reasons for using test doubles are:

  • You can not easily make the depended-on-component (DOC) behave as intended for your tests. For example, your code is robust and checks if another component's return state indicates some failure. To test your robustness code, you need the other component to return the failure status - but this may be horribly difficult to achieve or even impossible with the real component.
  • Does calling the DOC cause any non-derministic behaviour (date/time, randomness, network connections)? For example, if the computations of your code use the current time, then with the real DOC (that is, the time module) you would get different results for each test run.
  • Would the result that you want to test be some data that the code under test passes to the DOC, but the DOC has no API to obtain that data? For example, if your code under test writes its result to the console (the console being the DOC in this case), but there is no possibility for your tests to query the console what was written to it.
  • The test setup for the real DOC is overly complex and/or maintenance intensive (like, need for external files). For example, the DOC parses some configuration file at a fixed path. And, for different test cases you would need to configure the DOC differently and thus you would have to provide a different configuration file at that location.
  • The original DOC brings portability problems for your test code. For example if your function hashPassword uses some cryptographic hardware to compute the hash, but this hardware (or the proper hardware version) is not available on all hosts where the unit-tests are executed.
  • Does using the original DOC cause unnacceptably long build / execution times?
  • Has the DOC stability (maturity) issues that make the tests unreliable, or, worse, is the DOC not even available yet?
  • Maybe the DOC itself does not have any of the abovementioned problems, but comes with dependencies of its own, and the resulting set of dependencies leads to some of the problems mentioned above?

For example, you (typically) don't mock standard library math functions like sin or cos, because they don't have any of the abovementioned problems.

Dirk Herrmann
  • 5,550
  • 1
  • 21
  • 47