Страницы

Поиск по вопросам

понедельник, 6 января 2020 г.

Объясните принцип разработки TDD

#c_sharp #юнит_тесты #test_driven_development


Нашел такое задание в курсе по C#:

Implement Vending machine in code using TDD approach

There are following features:


You can update product list at any time
You can insert coins, get coins back and get remainder
You can buy 1 product at once for inserted coins
Machine accepts following coins: 5ȼ, 10ȼ, 20ȼ, 50ȼ, 1 € and 2 €


Есть такой интерфейс:

  public interface IVendingMachine
  {
    /// Vending machine manufacturer. 
    string Manufacturer { get; }

    /// Amount of money inserted into vending machine.  
    Money Amount { get; }

    /// Products that are sold. 
    Product[] Products { get; set; }

    /// Inserts the coin into vending machine. 
    /// Coin amount.
    Money InsertCoin(Money amount);

    /// Returns all inserted coins back to user. 
    Money ReturnMoney();

    /// Buys product from list of product. 
    /// Product number in vending machine product list.
    Product Buy(int productNumber);
  }

  public struct Money
  {
    public int Euros { get; set; }
    public int Cents { get; set; }
  }

  public struct Product
  {
    /// Gets orsets the available amount of product. 
    public int Available { get; set; }

    /// Gets orsetsthe product price. 
    public Money Price { get; set; }

    /// Gets orsetsthe product name. 
    public string Name { get; set; }
  }


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


Ответы

Ответ 1



Хорошее задание. Главное - у вас есть предметная область, а именно - то что надо уметь выполнять (требования и возможности сущности). В вашем случае - начинаем с создания класса, реализующего интерфейс. Пока пустого: public class VendingMachine : IVendingMachine { public Money Amount { get; } public Product[] Products { get; set; } public Money InsertCoin(Money amount) { throw new NotImplementedException(); } public Money ReturnMoney() { throw new NotImplementedException(); } public Product Buy(int productNumber) { throw new NotImplementedException(); } public string Manufacturer { get; } } Первое требование - You can update product list at any time Интерфейс не имплементирует, как именно это будет происходить, а потому главный вопрос к TDD - как именно стороннему разработчику будет удобно обновлять ассортимент. Пишем тест на это дело: var machine = new VendingMachine(); machine.AddProduct(); Сразу задумываемся, а как же удобно рулить продуктами, ведь судя по структуре продукта, в ней же задается количество\название и цена экземпляра. Я предпочту простой вариант: var beerPrice = new Money() {Euros = 1, Cents = 10 }; machine.AddOrUpdateProduct("Пиво", beerPrice, 3); Сразу добавим метод с такой сигнатурой в класс: public void AddOrUpdateProduct(string name, Money beerPrice, int count) { throw new NotImplementedException(); } Теперь дописываем тест для того, чтобы он проверял необходимый нам кейс: var machine = new VendingMachine(); var beerPrice = new Money() {Euros = 1, Cents = 10 }; var count = 3; machine.AddOrUpdateProduct("Пиво", beerPrice, count); Assert.AreEqual(machine.Products.Length, 1); Assert.AreEqual(machine.Products[0].Available, count); Запускаем тест - получаем ошибку: Метод проверки TDD.UnitTest1.UpdateVendingMachineProducts выдал исключение: System.NotImplementedException: Метод или операция не реализована.. Сложная часть работы завершена, осталось реализовать простой способ, который поднимет тест. Простой, но действующий так, будто сущность существует в реальном мире, не стоит городить магию. Работать с массивами я лично не люблю, поэтому спрячу в реализации список, в итоге: public Product[] Products { get { return products.ToArray(); } set { products = value.ToList(); } } private List products = new List(); public void AddOrUpdateProduct(string name, Money beerPrice, int count) { products.Add(new Product() {Available = count, Name = name, Price = beerPrice}); } Тест успешно прошел, первая фича реализована, ура. Вторая - You can insert coins, get coins back and get remainder Описание дает элементарный намек на три теста - вставил денег, вернул, получил сдачу. Первые два реализовать можно прямо сейчас, третий - лучше отложить, сдача может быть только от покупки, а про неё пока ни слова. Тут нам интерфейс задан вполне неплохо, пишем тесты по нему: [TestMethod] public void InsertCoin() { var machine = new VendingMachine(); var inserted = new Money() {Euros = 1}; var returned = machine.InsertCoin(inserted); Assert.AreEqual(machine.Amount, inserted); Assert.AreEqual(returned, inserted); } [TestMethod] public void ReturnMoney() { var machine = new VendingMachine(); var count = 1; machine.InsertCoin(new Money() { Euros = count }); var back = machine.ReturnMoney(); Assert.AreEqual(back.Euros, count); Assert.AreEqual(machine.Amount.Euros, 0); } Реализуем: Деньги перегрузим, пусть сами складываются. Пока элементарно: public static Money operator +(Money m1, Money m2) { return new Money() { Euros = m1.Euros + m2.Euros, Cents = m1.Cents + m2.Cents }; } Итого, для первого теста: public Money Amount { get; protected set; } public Money InsertCoin(Money amount) { Amount = Amount + amount; return Amount; } И для второго: public Money ReturnMoney() { var amount = Amount; Amount = new Money(); return amount; } Не забываем про предметную часть - сумма в автомате должна меняться при этих операциях. Ура, у нас три зеленых теста. Третий случай - You can buy 1 product at once for inserted coins Если я правильно понял, можно купить только один продукт за раз. После этого сдача вернётся. Не пользовался автоматами, если ошибся - ну извините. Тест меня сразу обломал - интерфейс ввода для пользователя - цифровой! Будем считать, что цифры от 1. Пишем тест: [TestMethod] public void Buy() { var machine = new VendingMachine(); var beerPrice = new Money() { Euros = 1, Cents = 10 }; var count = 3; var name = "Пиво"; machine.InsertCoin(new Money() { Euros = count }); machine.AddOrUpdateProduct(name, beerPrice, count); var product = machine.Buy(1); Assert.AreEqual(machine.Products[0].Available, count - 1); Assert.AreEqual(product.Name, name); Assert.AreEqual(machine.Amount, default(Money)); } Фух, снаружи простой тест вызывает кучу вопросов. Деньги проще сделать хотя бы частично работающими с простыми операциями: public static Money operator +(Money m1, Money m2) { return new Money() { Euros = m1.Euros + m2.Euros, Cents = m1.Cents + m2.Cents }; } public static Money operator -(Money m1, Money m2) { return new Money() { Euros = m1.Euros - m2.Euros, Cents = m1.Cents - m2.Cents }; } public static bool operator <(Money m1, Money m2) { if (m1.Euros != m2.Euros) return m1.Euros < m2.Euros; return m1.Cents < m2.Cents; } public static bool operator >(Money m1, Money m2) { return m2 < m1; } public static bool operator ==(Money m1, Money m2) { return m1.Euros == m2.Euros && m1.Cents == m2.Cents; } public static bool operator !=(Money m1, Money m2) { return !(m1 == m2); } Продукту это тоже не помешает: public static Product operator +(Product m1, int m2) { return new Product() { Available = m1.Available + m2, Price = m1.Price, Name = m1.Name }; } public static Product operator -(Product m1, int m2) { return new Product() { Available = m1.Available - m2, Price = m1.Price, Name = m1.Name }; } Тогда, покупка выглядит более-менее простой, я правда так и не понял, какой товар вернуть должен интерфейс - я возвращаю остаток в автомате, чтобы его можно было отобразить например. public Product Buy(int productNumber) { if (products.Count < productNumber || productNumber < 1) throw new IndexOutOfRangeException("Товара нет."); var index = productNumber - 1; var product = products[index]; if (product.Available <= 0) throw new IndexOutOfRangeException("Товара нет."); if (Amount < product.Price) throw new Exception("Не хватает денег."); Amount = Amount - product.Price; ReturnMoney(); product = product - 1; products[index] = product; return product; } Итак, третий тест теперь тоже зеленый. Если вы думаете, что код пишется легко - я вас уверяю, быстрый запуск теста позволяет писать его ещё легче. Все исключения в методе Buy написаны исключительно благодаря тесту, но даже так какие то кейсы я мог пропустить. Я не буду описывать последний кейс - в нём вся соль тестов. Надо написать тест, добавить ограничения и словить боль от падения предыдущих тестов, которые работали с покупкой и кривыми введенными данными. В этом вся соль TDD - вы написали себе логику прикладного разработчика, потом реализовали какую то внутреннюю и сложную хрень, а потом получили ту самую боль, которую испытывают все, кто пользуется вашей разработкой. Ваши страдания намного дешевле страданий пользователей, а потому TDD(который и PainDD вполне) и помогает писать тесты и стабильный продукт. Запускайте тест чаще при разработке - он сразу скажет, что вы забыли. При разработке методов - не забывайте проверять входные данные и данные окружающего мира, а то в тестах всегда есть желание дать невалидные данные и получить результат. Купить товар, не введя денег, запросить деньги, не добавляя их и прочее прочее. Чем больше у вас реальных кейсов поведения - тем конкретнее и полезнее будут ваши тесты, не стоит придумывать ерунды на тестирование. UPD: куча вещей пропущена и по причине того, что не нужны для тестов и по причине лени. Центы не складываются в евро, товар можно только добавить, хотя на деле нужно бы количественное добавление, цену товара нельзя поменять. Над чем то надо думать, что то уточнять у бизнес-аналитика или специалиста по предметной области - сложностей хватает.

Ответ 2



Понимание того что хотите создать. В общих чертах это представить Написать тест, как будто объект тестирования уже написан Запустить тест. Убедиться что тест упал. Это важно! Написать код в объекте тестирования код достаточный, чтобы скомпилировалось Запустить тест. Убедиться что тест пройден. Это важно! Написать код в объекте тестирования Запустить тест. Убедиться что тест пройден. Это важно! Провести рефакторинг, если нужно Запустить тест. Убедиться что тест пройден. Это важно! Нельзя пренебрегать запуском теста между этами разработки. Тест обязан падать там, где нужно упасть. Именно по этой причине п.3, п.5, п.7 и п.9 крайне важны! После того как объект тестирования реализован нужно убедиться, что тест: Находится в правильном наборе тестирования. Если это не так, то перенести куда следует; Содержит только и только одну проверку. Должна быть только и только одна причина по которой тест должен упасть. Другими словами должен быть только и только один assert. Если не получается, то воспользуйтесь библиотекой типа hamcrest; Выполняется достаточно быстро. Основное качество модульного теста это скорость работы. Если модульные тесты будут работать медленно, разработчик будет избегать их запуска.

Ответ 3



Насколько я понял сначала нужно реализовать тест метода а потом его написать Абсолютно верно. В этом и заключается основная идея разработки через тестирования (TDD). Определив интерфейс функции или класса, далее ты определяешь набор требований к функционалу через соответствие входных и выходных данных. Например тебе нужно реализовать поиск большего из 2х чисел (пример на С++, но смысл должен быть понятен) template T max(const T& a, const T& b); Условный набор тестов: assert(max(1, 4) == 4); assert(max(4, 1) == 4); assert(max(-1, -4) == -1); После этого ты начинаешь реализовывать метод и в конечном счете все тесты должны пройти. Аналогично с классом терминала продаж определив интерфейс пишешь тесты на то, как изменится внутреннее представление экземпляра класса при вызове его методов с разными параметрами

Ответ 4



не понимаю как можно написать сначала юнит-тест, а потом реализовать метод Это нормально. Этого никто не понимает. Не зная предметную область, не обсудив ТЗ с заказчиком и с коллегами, не имея большого опыта разработки в целом и опыта разработки в данной предметной области в частности, невозможно сразу начать писать тесты. Те, кто уверяют, что нужно сразу писать красный тест, слегка лукавят. Сколько я ни общался с такими разработчиками, сколько ни видел примеров в интернете, там всегда пишется тест на уже знакомую тему. То есть данный разработчик участвовал по крайней мере в одном проекте (а скорее в нескольких на протяжении ряда лет) в данной предметной области. И вот на основании своего предыдущего многолетнего опыта он и способен написать сперва тест. Но на самом деле, сперва был написан код: в предыдущих проектах. А как дело касается новой предметной области, опять начинается написание сперва пробных кусков кода, их переписывание, выбрасывание... Писать тест на код, который гарантированно будет выброшен, нет смысла. И лишь после нескольких итераций выкристаллизовывается некий каркас, по которому можно начинать писать тесты. Поэтому, не расстраивайтесь, когда у вас не будет сразу получаться писать сперва тесты и лишь потом код.

Комментариев нет:

Отправить комментарий