Статьи

Что я ненавижу в BDD

Отказ от ответственности: это не публикация «TDD против BDD» — теперь, когда мы обдумали это, давайте обсудим то, что я больше всего ненавижу в BDD…

Я недавно начал использовать BDD (снова). Мои тесты все еще являются «модульными тестами» — они не вызывают ни базу данных, ни какие-либо другие внешние зависимости, и, поскольку даже при написании модульных тестов я стремлюсь протестировать функциональность (мини-требования), большинство из них не изменились слишком сильно.

В качестве предмета этого эксперимента я выбрал BDDfy, который идеально подходил для моих нужд. 
В некоторых средах BDD используются два «вида» файлов — текстовые файлы, в которых записаны сценарии и истории (в виде простого текста), и файлы кода, которые реализуют функции, соответствующие шагам в этих сценариях. 

BDDfy, с другой стороны, использует файл кода, который также используется для генерации спецификации, поэтому для меня меньше работы.

Используя BDDfy, я могу преобразовать следующий модульный тест:

[TestMethod]
public void RejectCall_UserRecieveCallAndRejectIt_SendReport()
{
    var fakeMessageEngine = A.Fake<IMessageEngine>();
    var phone = new Phone(fakeMessageEngine);
    var notificationService = new NotificationService(phone);
 
    var message = new NewCallMessage("otherUserId");
    fakeMessageEngine.OnMessageArrived += Raise.With(new MessageArrivedEventArgs(message)).Now;
 
    phone.AnswerCall(Answer.Reject);
 
    var result = notificationService.GetLastReport();
 
    Assert.AreEqual(ReportType.CallEnded, result.ReportType);
}

Чтобы что-то вроде этого:

[TestClass]
public class NotificationBddTests
{
    IMessageEngine _fakeMessageEngine;
    Phone _phone;
    NotificationService _notificationService;
 
    public NotificationBddTests()
    {
        _fakeMessageEngine = A.Fake<IMessageEngine>();
        _phone = new Phone(_fakeMessageEngine);
        _notificationService = new NotificationService(_phone);
    }
 
    [TestMethod]
    public void RejectCall_UserRecieveCallAndRejectIt_SendReport()
    {
        new NotificationBddTests()
            .Given(s => s.RecievedCallFromUser())
            .When(s => s.CallRejected())
            .Then(s => s.EndCallReportAdded())
            .BDDfy();
    }
 
    private void RecievedCallFromUser()
    {
        var message = new NewCallMessage("otherUserId");
        _fakeMessageEngine.OnMessageArrived += Raise.With(new MessageArrivedEventArgs(message)).Now;
    }
 
    private void CallRejected()
    {
        _phone.AnswerCall(Answer.Reject);
    }
 
    private void EndCallReportAdded()
    {
        var result = _notificationService.GetLastReport();
 
        Assert.AreEqual(ReportType.CallEnded, result.ReportType);
    }
}

И это улучшение — тест более читабелен и хорошо организован. 

Но есть скрытая «цена» за написание такого рода тестов — которые могут вызвать хрупкие тесты и проблемы со стабильностью в ближайшем будущем — можете ли вы определить проблему?

Все дело в общем состоянии

Я должен сделать признание: большинство примеров кода BDD в Интернете заставляют меня съеживаться. Им всем присущ один и тот же недостаток. Все дело в том, «как мы тестируем реальные требования» и «создаем живую спецификацию», но никто никогда не говорит о том факте, что приведенный выше тест  может  стать огромной проблемой в обслуживании!

Проблема может быть легко показана на следующем примере:

[TestClass]
public class ShoppingCartTests
{
    private readonly ShoppingCart _cart = new ShoppingCart()
 
    [TestMethod]
    public void EmptyCartTest()
    {
        new ShoppingCartTests()
            .Given(s => s.ShoppingCartIsEmpty())
            .Then(s => s.TotalPriceEquals(0))
            .BDDfy();
    }
 
    [TestMethod]
    public void AddItemTest()
    {
        var newProduct = new Product(id: "prd-1", price: 100
        new ShoppingCartTests()
            .Given(s => s.ShoppingCartIsEmpty())
            .When(s => s.AddProductToCart(newProduct))
            .Then(s => s.TotalPriceEquals(newProduct.Price))
            .BDDfy();
    }
 
    private void ShoppingCartIsEmpty() { }
 
    private void TotalPriceEquals(int expected)
    {
        Assert.AreEqual(expected, _cart.TotalPrice);
    }
 
    private void AddProductToCart(Product product)
    {
        _cart.Add(product);
    }
}

Видите ли вы поле, которое используется всеми тестами.

Используя один и тот же экземпляр во всех тестах, мы случайно запускаем (или не выполняем) тест в зависимости от порядка их запуска. Очевидно, это легко исправить (в данном случае). К сожалению, такая простая проблема может стать огромной проблемой, чем больше написано тестов, тем больше требуется логики инициализации, и, прежде чем вы это узнаете, наступает середина ночи, и вы пытаетесь понять выяснить, как мог тест, который раньше работал, терпеть неудачу иногда — без видимой причины. 

Это не проблема BDD — я могу легко написать (и написать) один и тот же код без помощи среды BDD. Однако кажется, что вся структура BDD подталкивает вас к этой проблеме.

Решение

К счастью, эту проблему легко избежать полностью — убедитесь, что тесты не разделяют одно и то же состояние (я сказал, что это легко).

Я создал новый класс с именем  NotificationTestBuilder  (потому что наименование вещей — самая сложная вещь в программировании) и поместил в него все «состояние» теста.

И теперь я могу написать один и тот же тест BDD, используя одно состояние на тест:

public class NotificationTestBuilder
{
    IMessageEngine _fakeMessageEngine;
    Phone _phone;
    NotificationService _notificationService;
 
    public NotificationTestBuilder()
    {
        _fakeMessageEngine = A.Fake<IMessageEngine>();
        _phone = new Phone(_fakeMessageEngine);
        _notificationService = new NotificationService(_phone);
    }
 
    public void RecievedCallFromUser()
    {
        var message = new NewCallMessage("otherUserId");
        _fakeMessageEngine.OnMessageArrived += Raise.With(new MessageArrivedEventArgs(message)).Now;
    }
 
    public void CallRejected()
    {
        _phone.AnswerCall(Answer.Reject);
    }
 
    public void EndCallReportAdded()
    {
        var result = _notificationService.GetLastReport();
 
        Assert.AreEqual(ReportType.CallEnded, result.ReportType);
    }
}
 
[TestClass]
public class NotificationBddTests
{
    [TestMethod]
    public void RejectCall_UserRecieveCallAndRejectIt_SendReport()
    {
        var builder = new NotificationTestBuilder();
        new NotificationBddTests()
            .Given(s => builder.RecievedCallFromUser())
            .When(s => builder.CallRejected())
            .Then(s => builder.EndCallReportAdded())
            .BDDfy();
    }
}

И если мне нужно было провести «очистку» после завершения теста — я могу реализовать  IDisposable  и поместить весь тест в   блок использования .

И поэтому, создавая состояние в каждом тесте, мы следим за тем, чтобы они не могли влиять друг на друга. 

Удачного кодирования …