Pergunta

Hello I am building a demo project to teach myself about clean architecture and unit testing. My setup is very similar to this github repo https://github.com/mmacneil/CleanAspNetCoreWebApi

The important parts for this question are the following 2

  • The Core project which should contain the business logic
  • The Api project that handles receiving the requests and the presentation logic.

In my Api project (and also every other API that I have worked with) there is validation logic on the incoming request. For example: The request contains an Age field. I have a validation rule to check that Age > 0 as I don't want to go any further if the Age is invalid.

In my Core project there is a Use Case that has the following logic. If Age < 18 then the user is a child. Now in this use case I have not made any checks to ensure that Age < 0 returns an error as I know this has already been checked and I don't want to duplicate the logic.

I am writing unit tests for this Use Case and here is the thing. If I write a unit test with input Age = -1 and expect an error from the Use Case the test will naturally fail.

The reason for this problem is that the validation logic and the business logic are in two separate locations and this raises my question(s).

Is validation logic actually business logic and if so is it wrong to have validation logic anywhere outside the Core project? This does not feel correct because this means that all requests will pass to the Use Cases unfiltered (and I have seen in many web projects validations made as early as possible)

How do I test the above scenario if I don't move the validation logic to the Use Case? It doesn't feel right to just not write the Age = -1 test and trust that someone else before has made that check. Should I test this case with a different "system under test" at a higher level?

Foi útil?

Solução

Your question touches on multiple issues. I've tried to decouple them as best as I can.


Direct answers to your questions

Is validation logic actually business logic and if so is it wrong to have validation logic anywhere outside the Core project? This does not feel correct because this means that all requests will pass to the Use Cases unfiltered (and I have seen in many web projects validations made as early as possible)

Yes, business validation is part of the business logic. It makes no sense for a validator to not be part of the same domain as the logic which requires the validation to work.

The next chapter addresses why you'd want to have validation outside of a business layer, because it does have some (other) value.

How do I test the above scenario if I don't move the validation logic to the Use Case? It doesn't feel right to just not write the Age = -1 test and trust that someone else before has made that check. Should I test this case with a different "system under test" at a higher level?

It is a logical consequence that if you don't believe the validation should be part of the business layer, and you are solely trying to validate the business layer (by definition of what a unit test is), then you cannot expect to be able to test the validation in business logic unit tests.

You don't want to trust that someone wrote that check. I get that. But when you developed the business logic, you intentionally built it so that it has to trust that it receives data that was already validated.

You can't do both at the same time.

  • Either the business logic has to trust its consumer (the API in this case), but then your business logic cannot (and should not) try to test its validation.
  • Or the business logic does not trust anyone else, and therefore it performs its own validation to ensure that it receives data it can work with.

In my opinion, the latter is the much better option, as it enables you to reuse your business logic (e.g. if you decide to also make a WPF app for the same application) without risking the possibility that you forget to validate the data in this WPF app.


Where to put your validation

Validation considerations often end up with wondering where the validation should be. To paint a clearer picture, consider the following use case:

  • When you validate in your webpage, the user gets immediate feedback (good), but if the user somehow sends unvalidated data to the backend (which does not validate), then you're still going to get bad data.
  • When you validate in your backend, you ensure that no bad data gets through (good), but it requires the user to try and send the data to get the validation feedback, which is a slower and more painstaking user experience (bad).

This touches on something you mentioned :

Is validation logic actually business logic and if so is it wrong to have validation logic anywhere outside the Core project? This does not feel correct because this means that all requests will pass to the Use Cases unfiltered (and I have seen in many web projects validations made as early as possible)

The solution here (in my given example) is to implement both validations. Yes, this doesn't strictly adhere to DRY, but implementing only one validation always brings a negative. Implementing both does not (at the cost of some extra effort), and when you want to avoid both possible negatives, implementing it twice is the best way to do so.

The reason I mention this is because you seem to have put your validation logic in a weird place.

In my Api project (and also every other API that I have worked with) there is validation logic on the incoming request.

Now in this use case I have not made any checks to ensure that Age < 0 returns an error as I know this has already been checked and I don't want to duplicate the logic.

You're suffering from a similar issue as the validation example I gave. The layer where the validity of the data is important (business logic) is not the layer that validates the data (API). Therefore, whenever a different consumer consumes the business logic (e.g. the unit tests), you suddenly lose the validation that you were supposed to be depending on.

Important note: The reason I suggest writing the validation twice _in my given example_ is because the browser and the backend live in their own domains and a constant data exchange between the two is not really bandwidth- or performance-friendly.
In your case, the API and business logic may be in different assemblies but they will live in the same runtime execution, so it makes no sense to duplicate it as data exchange between the layers is irrelevant post-compilation. Here, it becomes a matter of moving the validation rather than duplicating it.

Generally, your API should not be doing business validation. Business validation belongs to the business layer. When your API implements validation, it should be validating the technical correctness (e.g. is age an integer?)
When you have modelbinders, that validation can be inherently incorporated and you don't need to explicitly write API validation anymore.

Therefore, the age check to see if the person is a minor or an adult should be happening in the business layer. Depending on your structure, this could be a method of your business logic class, or a separate class (dependency) by itself. In either case, it needs to live in the business logic layer and that's the important thing to take away here.


Validation during unit tests

Ignoring unit tests that focus on validation behavior for a moment, unit tests generally do not require validation, specifically because you purposely chose the data to test with. If you can't trust yourself to provide test data that passes the validation, then how could you ever trust yourself to provide the appropriate test data for your unit tests?

At a basic level, a validation yields a singular boolean value. Either the validation passes, or it doesn't.

For the sake of example, I'm going to assume that your validator is an injected dependency in your business logic class as I suggested above. Something along the lines of:

var person = new Person() { Name = "John", Age = 15 };
var validator = new PersonValidator();

var logic = new PersonLogic(validator);

bool personWasCreated = logic.Create(person);

This means that you can simply mock your validator, and instead of actually performing the validation, you simply force the (mocked) validator to pass/fail without actually performing any validation.

Assuming this is your validator:

public interface IPersonValidator
{
    bool IsValid(Person p);
}

public class PersonValidator : IPersonValidator
{
    public bool IsValid(Person p)
    {
        return p.Age > 18;
    }
}

You can then create a mocked version:

public class PassingPersonValidator : IPersonValidator
{
    public bool IsValid(Person p)
    {
        return true;
    }
}

public class FailingPersonValidator : IPersonValidator
{
    public bool IsValid(Person p)
    {
        return false;
    }
}

I'm more a fan of a mocked class where you can dynamically set the value:

public class MockedPersonValidator : IPersonValidator
{
    private bool fixedValue;

    public MockedPersonValidator(bool value)
    {
        this.fixedValue = value;
    }

    public bool IsValid(Person p)
    {
        return this.fixedValue;
    }
}

But you can use either. It's mostly a matter of what you find the most readable.

This enables you to unit test your business logic without needing to rely on actual validation. Just like any other dependency, you mock it.

//Testing how the logic responds to invalid data:

var person = new Person() { Name = "John" };
var validator = new MockedPersonValidator(false);
var logic = new PersonLogic(validator);

bool personWasCreated = logic.Create(person);

Assert.IsFalse(personWasCreated);

//Testing how the logic responds to valid data:

var person = new Person() { Name = "John" };
var validator = new MockedPersonValidator(true);
var logic = new PersonLogic(validator);

bool personWasCreated = logic.Create(person);

Assert.IsTrue(personWasCreated);

Notice that we don't even need to care that we pass the right age for the person! The business logic depends on a validator who we've "rigged" to claim that the data is valid. The business logic itself does not actually know if the data is valid or not as that's not its reponsibility.

Since your validator is in a separate class, this means you can also test it separately:

// Confirming that validation fails for minors:

var person = new Person() { Name = "John", Age = 15 };
var validator = new PersonValidator();

Assert.IsFalse(validator.IsValid(person));

// Confirming that validation passes for adults:

var person = new Person() { Name = "John", Age = 25 };
var validator = new PersonValidator();

Assert.IsTrue(validator.IsValid(person));

Note that I've kept the example unit tests very short and simple for clarity's sake.

Outras dicas

For internal functions that are only ever called by trusted code (code that you have sufficient control over that passing wrong parameters will get fixed), then there is no need to re-validate parameters that are known to be valid.

Thus, if your Core project can only be called by the Api project, and you trust the Api project to catch all attempts at invalid data, then the Core project can just state that passing in an invalid Age value will result in undefined behaviour. This means that any code that passes an age of -1 contains a bug and that includes any testcase that tries to do so.

Licenciado em: CC-BY-SA com atribuição
scroll top