Модульное тестирование богатой доменной модели

Это была анемичная модель предметной области:

public partial class Person
{
    public virtual int PersonId { get; internal protected set; }
    public virtual string Title { get; internal protected set; } 
    public virtual string FirstName { get; internal protected set; } 
    public virtual string MiddleName { get; internal protected set; } 
    public virtual string LastName { get; internal protected set; } 
}

И это его поведение:

public static class Services
{

    public static void UpdatePerson(Person p, string firstName, string lastName)
    {
        // validate  firstname and lastname
        // if there's a curse word, throw an exception


        // if valid, continue

        p.FirstName = firstName;
        p.LastName = lastName;


        p.ModifiedDate = DateTime.Now;
    }

}

И это в значительной степени тестируемо:

[TestMethod]

public void Is_Person_ModifiedDate_If_Updated()
{
    // Arrange
    var p = new Mock<Person>();

    // Act 
    Services.UpdatePerson(p.Object, "John", "Lennon");

    // Assert            
    p.VerifySet(x => x.ModifiedDate = It.IsAny<DateTime>());
}

Тем не менее, я хотел попрактиковаться в Rich Domain Model, где данные и поведение являются более логически связными. Таким образом, приведенный выше код теперь преобразуется в:

public partial class Person
{
    public virtual int PersonId { get; internal protected set; }
    public virtual string Title { get; internal protected set; }
    public virtual string FirstName { get; internal protected set; } 
    public virtual string MiddleName { get; internal protected set; }
    public virtual string LastName { get; internal protected set; } 

    public virtual void UpdatePerson(string firstName, string lastName)
    {
        // validate  firstname and lastname
        // if there's a curse word, throw an exception


        // if valid, continue


        this.FirstName = firstName;
        this.LastName = lastName;

        this.ModifiedDate = DateTime.Now;
    }           
}

Однако я сталкиваюсь с проблемой тестирования:

[TestMethod]
public void Is_Person_ModifiedDate_If_Updated()
{
    // Arrange
    var p = new Mock<Person>();

    // Act 
    p.Object.UpdatePerson("John", "Lennon");

    // Assert            
    p.VerifySet(x => x.ModifiedDate = It.IsAny<DateTime>());
}

Ошибка юнит-теста:

Result Message: 

Test method Is_Person_ModifiedDate_If_Updated threw exception: 
Moq.MockException: 
Expected invocation on the mock at least once, but was never performed: x => x.ModifiedDate = It.IsAny<DateTime>()
No setups configured.

Performed invocations:
Person.UpdatePerson("John", "Lennon")
Result StackTrace:  
at Moq.Mock.ThrowVerifyException(MethodCall expected, IEnumerable`1 setups, IEnumerable`1 actualCalls, Expression expression, Times times, Int32 callCount)
   at Moq.Mock.VerifyCalls(Interceptor targetInterceptor, MethodCall expected, Expression expression, Times times)
   at Moq.Mock.VerifySet[T](Mock`1 mock, Action`1 setterExpression, Times times, String failMessage)
   at Moq.Mock`1.VerifySet(Action`1 setterExpression)
   at Is_Person_ModifiedDate_If_Updated()

Видя, что непосредственно вызывается метод из объекта mocked, mocked объект не может обнаружить, было ли вызвано какое-либо его свойство или метод. Заметив это, как правильно провести модульное тестирование модели Rich Domain?

2 ответа

Решение

Во-первых, не издевайтесь над объектами или классами значений, которые вы тестируете. Также вы не проверяете, что человеку была предоставлена ​​правильная дата изменения. Вы проверяете, что какая-то дата была назначена. Но это не доказывает, что ваш код работает, как ожидалось. Чтобы протестировать такой код, вы должны смоделировать текущую дату, возвращенную DateTime.Now, или создать некоторую абстракцию, которая предоставит текущее время для обслуживания. Ваш первый тест должен выглядеть так (я использовал Fluent Assertions и NUnit здесь):

[Test]
public void Should_Update_Person_When_Name_Is_Correct()
{
    // Arrange
    var p = new Person(); // person is a real class
    var timeProviderMock = new Mock<ITimeProvider>();
    var time = DateTime.Now;
    timeProviderMock.Setup(tp => tp.GetCurrentTime()).Returns(time);
    Services.TimeProvider = timeProviderMock.Object;
    // Act 
    Services.UpdatePerson(p, "John", "Lennon");
    // Assert
    p.FirstName.Should().Be("John");
    p.LastName.Should().Be("Lennon");
    p.ModifiedDate.Should().Be(time); // verify that correct date was set
    timeProviderMock.VerifyAll();
}

Тайм-провайдер - это простая абстракция:

public interface ITimeProvider
{
    DateTime GetCurrentTime();
}

Я бы пошел с одноэлементной службой вместо статического класса, потому что статические классы всегда являются проблемой - высокая связь, отсутствие абстракции, сложные для модульного тестирования зависимые классы. Но вы можете ввести время провайдера через свойство:

public static class Services
{
    public static ITimeProvider TimeProvider { get; set; }

    public static void UpdatePerson(Person p, string firstName, string lastName)
    {
        p.FirstName = firstName;
        p.LastName = lastName;
        p.ModifiedDate = TimeProvider.GetCurrentTime();
    }
}

То же относится и к вашему второму тесту. Не издевайтесь над объектом, который вы тестируете. Вы должны проверить реальный код, который будет использовать ваше приложение, вместо того, чтобы тестировать какой-то макет, который используется только тестом. Тест с моделью доменного имени будет выглядеть так:

[Test]
public void Should_Update_Person_When_Name_Is_Correct()
{
    // Arrange        
    var timeProviderMock = new Mock<ITimeProvider>();
    var time = DateTime.Now;
    timeProviderMock.Setup(tp => tp.GetCurrentTime()).Returns(time);
    var p = new Person(timeProviderMock.Object); // person is a real class
    // Act 
    p.Update("John", "Lennon");
    // Assert
    p.FirstName.Should().Be("John");
    p.LastName.Should().Be("Lennon");
    p.ModifiedDate.Should().Be(time); // verify that correct date was set
    timeProviderMock.VerifyAll();
}

Ваш звонок:

p.Object.UpdatePerson("John", "Lennon");

называет публику virtual метод UpdatePerson на твоей насмешке. Твое издевательство имеет поведение Loose (также известен как Default), а вы нет Setup этот виртуальный метод.

Поведение Moq в этом случае - просто ничего не делать в его реализации (переопределении) UpdatePerson,

Есть несколько способов изменить это.

  • Вы могли бы удалить virtual ключевое слово из UpdatePerson метод. Тогда Moq не будет (и не сможет) переопределить свое поведение.
  • Или вы могли бы на самом деле Setup виртуальный метод с Moq, прежде чем вызывать его. (В этом случае бесполезно, поскольку он переопределяет метод, который вы на самом деле хотите проверить.)
  • Или вы можете сказать p.CallBase = true; прежде чем вызывать метод. Это работает следующим образом (с Loose поведение): если virtual вызывается член, который не был настроен, Moq будет вызывать реализацию базового класса.

Это объясняет, что вы видели. Я могу согласиться с советом, который дает Сергей Березовский в своем ответе.

Другие вопросы по тегам