dimanche 9 février 2020

Performing non-mock, state-based unit testing of non-trivial functions and their dependencies that follow CQS

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.

Aucun commentaire:

Enregistrer un commentaire