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

核能气质少年 提交于 2020-07-31 04:57:46

问题


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:


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.



来源:https://stackoverflow.com/questions/60139201/performing-non-mock-state-based-unit-testing-of-non-trivial-functions-and-their

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!