Як ви структуруєте одиничні тести для декількох об'єктів, які проявляють однакову поведінку?


9

У багатьох випадках у мене може бути існуючий клас з деякою поведінкою:

class Lion
{
    public void Eat(Herbivore herbivore) { ... }
}

... і у мене є одиничний тест ...

[TestMethod]
public void Lion_can_eat_herbivore()
{
    var herbivore = buildHerbivoreForEating();
    var test = BuildLionForTest();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

Тепер, що трапляється, мені потрібно створити клас Тигра з ідентичною поведінкою, як у Лева:

class Tiger
{
    public void Eat(Herbivore herbivore) { ... }
}

... і оскільки я хочу однакову поведінку, мені потрібно провести той же тест, я роблю щось подібне:

interface IHerbivoreEater
{
    void Eat(Herbivore herbivore);
}

... і я перероблю тест:

[TestMethod]
public void Lion_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildLionForTest);
}


public void IHerbivoreEater_can_eat_herbivore(Func<IHerbivoreEater> builder)
{
    var herbivore = buildHerbivoreForEating();
    var test = builder();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

... а потім я додаю ще один тест для свого нового Tigerкласу:

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

... і тоді я переробляю свої Lionта Tigerкласи (зазвичай за спадщиною, але іноді за складом):

class Lion : HerbivoreEater { }
class Tiger : HerbivoreEater { }

abstract class HerbivoreEater : IHerbivoreEater
{
    public void Eat(Herbivore herbivore) { ... }
}

... і все добре. Однак, оскільки функціональність зараз знаходиться в HerbivoreEaterкласі, тепер відчувається, що щось не так у тому, щоб перевірити кожну з цих форм поведінки на кожному підкласі. Тим НЕ менше , це підкласи, які фактично спожиті, і це лише деталь реалізації , що вони відбуваються спільно перекривається поведінку ( Lionsі Tigersможе мати абсолютно різні кінцеві використання, наприклад).

Тестування одного і того ж коду здається декількома разів, але є випадки, коли підклас може і перевершує функціональність базового класу (так, це може порушити LSP, але дозволяє зіткнутися з ним, IHerbivoreEaterпросто зручний інтерфейс для тестування - це може не мати значення для кінцевого споживача). Отже, я думаю, що ці тести мають певну цінність.

Що роблять інші люди в цій ситуації? Ви просто переміщуєте свій тест до базового класу чи ви перевіряєте всі підкласи на очікувану поведінку?

Редагувати :

Виходячи з відповіді від @pdr, я думаю, що ми повинні це врахувати: IHerbivoreEaterце лише договір про підпис методу; це не визначає поведінку. Наприклад:

[TestMethod]
public void Tiger_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildTigerForTest);
}

[TestMethod]
public void Cheetah_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildCheetahForTest);
}

[TestMethod]
public void Lion_eats_herbivore_head_first()
{
    IHerbivoreEater_eats_herbivore_head_first(BuildLionForTest);
}

На думку аргументів, чи не повинен у вас Animalклас, який містить Eat? Всі тварини їдять, а тому Tigerі Lionклас може успадкувати від тварини.
The Muffin Man

1
@Nick - це хороший момент, але я думаю, що це інша ситуація. Як зазначав @pdr, якщо ви ставите Eatповедінку в базовий клас, то всі підкласи повинні проявляти однакову Eatповедінку. Однак я говорю про 2 відносно неспоріднених класи, які мають поділитися поведінкою. Розглянемо, наприклад, Flyповедінку Brickта, Personяке, ми можемо припустити, виявляють схожу поведінку літаючих, але це не обов'язково має сенс, щоб вони походили із загального базового класу.
Скотт Вітлок

Відповіді:


6

Це чудово, оскільки показує, як тести справді керують тим, як ви думаєте про дизайн. Ви відчуваєте проблеми в дизайні і задаєте правильні питання.

Існує два способи дивитися на це.

IHerbivoreEater - це договір. Усі IHerbivoreEaters повинні мати метод Eat, який приймає травоїдних. Тепер ваші тести не цікавлять, як його їдять; ваш Лев може початися з пустоти, а Тигр може початися в горлі. Все, що вас цікавить, полягає в тому, що після того, як він закликає Їсти, Травоїдну тварину з'їдають.

З іншого боку, частина того, що ви говорите, - це те, що всі IHerbivoreEaters їдять травоїдну тварину точно так само (звідси базовий клас). У цьому випадку немає жодного сенсу укладати контракт на IHerbivoreEater. Він нічого не пропонує. Ви також можете просто успадкувати від HerbivoreEater.

А може, покінчити з Левом і Тигром повністю.

Але, якщо Лев і Тигр відрізняються у будь-якому сенсі, крім своїх харчових звичок, то вам потрібно почати цікавитись, чи не збираєтеся ви стикатися з проблемами зі складним деревом спадкування. Що робити, якщо ви також хочете отримати обидва класи від Feline або лише клас Lion від KingOfItsDomain (можливо, разом із Shark). Ось де справді входить ЛСП.

Я б припустив, що загальний код краще інкапсульований.

public class Lion : IHerbivoreEater
{
    private IHerbivoreEatingStrategy _herbivoreEatingStrategy;
    private Lion (IHerbivoreEatingStrategy herbivoreEatingStrategy)
    {
        _herbivoreEatingStrategy = herbivoreEatingStrategy;
    }

    public Lion() : this(new StandardHerbivoreEatingStrategy())
    {
    }

    public void Eat(Herbivore herbivore)
    {
        _herbivoreEatingStrategy.Eat(herbivore);
    }
}

Те саме стосується і Тигра.

А ось ось що розвивається гарна річ (гарна тому, що я цього не мав намір). Якщо ви просто зробите цей приватний конструктор доступним для тесту, ви можете передати фальшиву IHerbivoreEatingStrategy і просто перевірити, чи повідомлення передано на інкапсульований об'єкт належним чином.

І ваш складний тест, той, про який ви хвилювали, в першу чергу, повинен лише перевірити StandardHerbivoreEatingStrategy. Один клас, один набір тестів, жодного дублюваного коду для турботи.

І якщо пізніше ви хочете сказати Тиграм, що вони повинні їсти своїх травоїдних тварин по-іншому, жоден з цих тестів не повинен змінюватися. Ви просто створюєте новий HerbivoreEatingStrategy і тестуєте це. Електропроводка перевіряється на рівні інтеграції.


+1 "Стратегія" - це перше, що мені прийшло в голову, читаючи питання.
StuperUser

Дуже добре, але тепер замініть "модульний тест" в моєму питанні на "інтеграційний тест". Хіба ми не закінчимось тією самою проблемою? IHerbivoreEaterє контрактом, але лише в тій мірі, скільки мені потрібно для тестування. Мені здається, що це один випадок, коли введення качок справді допомогло б. Я просто хочу відправити їх обох у тій же логіці тестування зараз. Я не думаю, що інтерфейс повинен обіцяти поведінку. Тести повинні це робити.
Скотт Вітлок

чудове запитання, чудова відповідь. Вам не потрібно мати приватного конструктора; ви можете підключити IHerbivoreEatingStrategy до StandardHerbivoreEatingStrategy, використовуючи контейнер IoC.
ажеглов

@ScottWhitlock: "Я не думаю, що інтерфейс повинен обіцяти поведінку. Тести повинні це робити". Саме це я і кажу. Якщо вона дійсно обіцяє поведінку, вам слід позбутися її і просто скористатися класом (базовий). Вам взагалі не потрібен для тестування.
pdr

@azheglov: Погодьтеся, але моя відповідь була досить довгою :)
pdr

1

Це здається зайвим тестувати один і той же код кілька разів, але є випадки, коли підклас може і перевершує функціональність базового класу

Однозначно ви запитуєте, чи доцільно використовувати знання з білої коробки, щоб опустити деякі тести. З точки зору чорного ящика, Lionі Tigerрізні класи. Тож хтось без ознайомлення з кодом перевірив би їх, але ви з глибокими знаннями про впровадження знаєте, що ви можете піти, просто випробувавши одну тварину.

Частина причини тестування модулів полягає в тому, щоб дозволити собі пізніше рефакторувати, але підтримувати той же інтерфейс чорної скриньки . Експертні тести допомагають вам забезпечити, щоб ваші заняття продовжували виконувати їхні контракти з клієнтами, або, принаймні, змушують вас усвідомити та продумати, як контракт може змінитися. Ви самі усвідомлюєте це, Lionабо можете Tigerперекрити Eatякийсь пізній момент. Якщо це можливо дистанційно, просте тестове тестування, яке може з'їсти кожна тварина, яку ви підтримуєте, таким чином:

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

це повинно бути дуже простим у виконанні та достатнім та гарантуватиме, що ви зможете виявити, коли об’єкти не виконують свій договір.


Цікаво, чи справді це питання просто зводиться до переваги тестування «чорна скринька» та «білого поля». Я схиляюся до табору чорних ящиків, тому, можливо, саме тому я тестую те, що є. Дякуємо, що вказали на це.
Скотт Вітлок

1

Ви все робите правильно. Подумайте про тест одиниці як про перевірку поведінки одноразового використання нового коду. Це той самий дзвінок, який ви б зробили з виробничого коду.

У цій ситуації ви абсолютно правильні; Користувач Лева чи Тигра не буде (принаймні не доведеться) дбати про те, що вони обидва HerbivoreEaters і що код, який фактично працює для методу, є загальним для обох в базовому класі. Аналогічно, користувач реферату HerbivoreEater (наданий конкретним Левом чи Тигром) не хвилює, який у нього є. Що їх хвилює, це те, що їхній Лев, Тигр або невідома конкретна реалізація HerbivoreEater правильно з'їсть () Травоїдних.

Отже, те, що ви в основному випробовуєте, - це те, що Лев їсть, як задумано, і що Тигр з'їсть за призначенням. Важливо тестувати обидва, оскільки не завжди може бути правдою, що вони обидва харчуються абсолютно однаково; тестуючи обидва, ви гарантуєте, що той, якого ви не хотіли змінити, не зробив. Оскільки це обидва визначених Травоядних Пожиральників, принаймні, поки ви не додасте Гепард, ви також перевірили, що всі Травоїдні Пожирачі будуть їсти за призначенням. Ваше тестування як повністю охоплює, так і належним чином виконує код (за умови, що ви також зробите всі очікувані твердження про те, що повинно бути результатом того, що HerbivoreEater їсть травоїдне дерево).

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.