chivandikwa / Ease

Test builders done with ease and done right
Apache License 2.0
8 stars 0 forks source link

How to set properties after construction with private set? #2

Open LockTar opened 1 year ago

LockTar commented 1 year ago

Hi,

Do you have an idea on how to set properties with a private set after construction?

For simplicity I've created a Customer object.

public class Customer
{
    public int Id { get; init; }

    public string Name { get; init; }

    public string Email { get; private set; }

    public int Age { get; private set; }

    public Customer(int id, string name)
    {
        Id = id;
        Name = name ?? throw new ArgumentNullException(nameof(name));
    }

    public void SetEmail(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
        {
            throw new ArgumentException($"'{nameof(email)}' cannot be null or whitespace.", nameof(email));
        }

        Email = email;
    }

    public void SetAge(int age)
    {
        if (age < 18)
        {
            throw new ArgumentException(nameof(age));
        }

        Age = age;
    }
}

The Builder. Unable to set the Age property

internal class CustomerBuilder : Builder<Customer>
{
    private readonly Faker _faker = new();

    protected override Customer CreateInstance()
    {
        var customer = new Customer(Get(x => x.Id), Get(x => x.Name));
        return customer;
    }

    public override Builder<Customer> ThatIsValid()
    {
        With(x => x.Id, 5);
        With(x => x.Name, _faker.Person.FullName);
        With(x => x.Age, 23); // This doesn't work...

        return this;
    }
}

What does work is the following:

internal class CustomerBuilder : Builder<Customer>
{
    private readonly Faker _faker = new();

    protected override Customer CreateInstance()
    {
        var customer = new Customer(Get(x => x.Id), Get(x => x.Name));

        customer.SetAge(Get(x => x.Age));
        // if I have more properties I can fill them with other methods here

        return customer;
    }

    public override Builder<Customer> ThatIsValid()
    {
        With(x => x.Id, 5);
        With(x => x.Name, _faker.Person.FullName);
        With(x => x.Age, 23); // You must also set it of course

        return this;
    }
}

But let's say I have strange business logic. For example the SetEmail can only be called when Age is 0. So actually not being set yet. I could add all those Setxxxx methods in the CreatInstance method but then they always get values. Then I have to overwrite the ThatIsValid values with in example Age is 0 again. Not really ideal...

I know it is strange but think of a registration process. I have a Status property and I need to set some properties only when the status is a particular value. It is not possible now.

Possible solution I think the solution is to introduce a Has method in the builder so you can do this:

internal class CustomerBuilder : Builder<Customer>
{
    private readonly Faker _faker = new();

    protected override Customer CreateInstance()
    {
        var customer = new Customer(Get(x => x.Id), Get(x => x.Name));

        if (Has(x => x.Age))
        {
            customer.SetAge(Get(x => x.Age));
        }

        if (Has(x => x.Email))
        {
            customer.SetEmail(Get(x => x.Email));
        }

        return customer;
    }

    public Builder<Customer> ThatIsValidWithAgeNotSet()
    {
        With(x => x.Id, 5);
        With(x => x.Name, _faker.Person.FullName);

        return this;
    }

    public override Builder<Customer> ThatIsValid()
    {
        With(x => x.Id, 5);
        With(x => x.Name, _faker.Person.FullName);
        With(x => x.Age, 23);
        With(x => x.Email, _faker.Person.Email);

        return this;
    }
}
chivandikwa commented 1 year ago

Hi. Thanks for the question.

Indeed making use of the CreateInstance is the approach when the creation cannot easily be inferred and yes having multiple ThatIsValid variants when your domain has multiple such states also would make sense. To make sure I fully understand, what would the proposed Has give you over using Get?

LockTar commented 1 year ago

Ok real scenario because then it's easier to explain.

I have a customer registration. This is a normal process with a status from 1 until 4.

public class CustomerRegistration
{
    public string Id { get; init; }
    public int Status { get; private set; }
    public string Name { get; private set; }
    public string Email { get; private set; }
    public bool? ConsentGiven { get; private set; }

    public CustomerRegistration(string id, string name, string email)
    {
        Id = id ?? throw new ArgumentNullException(nameof(id));
        Name = name ?? throw new ArgumentNullException(nameof(name));
        Email = email ?? throw new ArgumentNullException(nameof(email));

        Status = 1;
    }

    public void SetEmailVerifiedStatus()
    {
        if (Status != 1)
        {
            throw new Exception();
        }

        Status = 2;
    }

    public void SetConsent(bool consent)
    {
        if (Status != 2)
        {
            throw new Exception();
        }

        Status = 3;
        ConsentGiven = consent;
    }

    public void CompleteRegistration()
    {
        if (Status != 3)
        {
            throw new Exception();
        }

        Status = 4;
    }
}

Because of the constructor I need to make use of the CreateInstance. But this will only fill Id, name and email.

When we are in example Status 2 (so after the email is verified) the user needs to consent something. In order to test this functionality (move from status 2 to 3), I need to be able to set the Status to value 2. This is ignored by the builder. I think because of the private set. Doesn't matter if use the With or the ThatIsValid method.

So I need to "set" it in the CreateInstance. So you get something like this:

internal class CustomerRegistrationBuilder : Builder<CustomerRegistration>
{
    private readonly Faker _faker = new();

    protected override CustomerRegistration CreateInstance()
    {
        var registration = new CustomerRegistration(Get(x => x.Id), Get(x => x.Name), Get(x => x.Email));

        if (Has(x => x.Status))// So check if a property has even set...
        {
            int status = Get(x => x.Status);

            switch (status)
            {
                case 2:
                    registration.SetEmailVerifiedStatus();
                    break;
                case 3:
                    registration.SetConsent(Get(x => x.ConsentGiven.Value));
                    break;
                case 4:
                    registration.CompleteRegistration();
                    break;
            }
        }

        return registration;
    }

    public CustomerRegistrationBuilder WithStatus(int status)
    {
        With(x => x.Status, status);

        return this;
    }

    public CustomerRegistrationBuilder ThatIsValidUntilStatusAccountInfoReceived()
    {
        With(x => x.Id, Guid.NewGuid().ToString());
        With(x => x.Status, 1);
        With(x => x.Name, _faker.Person.FullName);
        With(x => x.Email, _faker.Person.Email);

        return this;
    }

    public CustomerRegistrationBuilder ThatIsValidUntilStatusEmailVerified()
    {
        With(x => x.Id, Guid.NewGuid().ToString());
        With(x => x.Status, 2); // Is ignored here https://github.com/chivandikwa/Ease/issues/2
        With(x => x.Name, _faker.Person.FullName);
        With(x => x.Email, _faker.Person.Email);

        return this;
    }

    public CustomerRegistrationBuilder ThatIsValidWithStatusCompleted()
    {
        With(x => x.Id, Guid.NewGuid().ToString());
        With(x => x.Status, 4); // Is ignored here https://github.com/chivandikwa/Ease/issues/2
        With(x => x.Name, _faker.Person.FullName);
        With(x => x.Email, _faker.Person.Email);
        With(x => x.ConsentGiven, true); // Is ignored here https://github.com/chivandikwa/Ease/issues/2

        return this;
    }

    public override CustomerRegistrationBuilder ThatIsValid()
    {
        return ThatIsValidUntilStatusAccountInfoReceived();
    }
}

Of course this sample is simplified. But I don't see another way to set properties like ConsentGiven only when a particular status is set...

Hopefully you understand the issue.