Модульное тестирование богатой доменной модели
Это была анемичная модель предметной области:
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 будет вызывать реализацию базового класса.
Это объясняет, что вы видели. Я могу согласиться с советом, который дает Сергей Березовский в своем ответе.