Как вы структурируете модульные тесты для нескольких объектов, которые демонстрируют одинаковое поведение?

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);
}

Теперь, что происходит, мне нужно создать класс Tiger с поведением, идентичным поведению Lion:

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класс мог наследовать от животного.
Маффин Ман
1
@ Ник - это хороший момент, но я думаю, что это другая ситуация. Как указал @pdr, если вы поместите Eatповедение в базовый класс, то все подклассы должны демонстрировать одинаковое Eatповедение. Тем не менее, я говорю о 2 относительно не связанных классах, которые имеют общее поведение. Рассмотрим, например, Flyповедение Brickи Personкоторое, как мы можем предположить, демонстрируют похожее поведение при полете, но не обязательно иметь смысл выводить их из общего базового класса.
Скотт Уитлок

Ответы:

6

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

Есть два способа посмотреть на это.

IHerbivoreEater - это контракт. Все IHerbivoreEaters должны иметь метод Eat, который принимает Herbivore. Теперь ваши тесты не заботятся о том, как их едят; Ваш Лев может начать с бедер, а Тигр - с горла. Все, что вас волнует, это то, что после вызова Eat травоядное съедается.

С другой стороны, часть того, что вы говорите, состоит в том, что все едоки IHerbivore едят травоядных точно так же (отсюда и базовый класс). В таком случае нет никакого смысла заключать договор с IHerbivoreEater. Ничего не предлагает. Вы также можете просто наследовать от HerbivoreEater.

Или, может быть, полностью избавиться от Льва и Тигра.

Но если Лев и Тигр отличаются во всех смыслах, кроме их привычек питания, то вам нужно задуматься, не столкнетесь ли вы с проблемами со сложным деревом наследования. Что делать, если вы также хотите извлечь оба класса из Feline или только класс Lion из KingOfItsDomain (возможно, вместе с Shark). Это где LSP действительно приходит.

Я бы предположил, что общий код лучше инкапсулирован.

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: «Я не думаю, что интерфейс должен давать обещания о поведении. Тесты должны делать это». Это именно то, что я говорю. Если это обещает поведение, вы должны избавиться от него и просто использовать (base-) класс. Вам не нужно это для тестирования вообще.
фунтовые
@azheglov: согласен, но мой ответ уже был достаточно длинным :)
pdr
1

Кажется излишним тестировать один и тот же код несколько раз, но есть случаи, когда подкласс может переопределять функциональность базового класса.

В общих чертах вы спрашиваете, целесообразно ли использовать знания белого ящика, чтобы пропустить некоторые тесты. С точки зрения черного ящика, Lionи Tigerразные классы. Таким образом, кто-то, не знакомый с кодом, может их протестировать, но вы, обладая глубокими знаниями в области реализации, знают, что вы можете просто протестировать одно животное.

Одной из причин разработки модульных тестов является возможность рефакторинга позже, но с тем же интерфейсом черного ящика . Модульные тесты помогают вам убедиться, что ваши классы продолжают выполнять свой контракт с клиентами, или, по крайней мере, заставляет вас осознать и тщательно обдумать, как контракт может измениться. Вы сами понимаете это Lionили Tigerмогли бы переопределить Eatв какой-то момент позже. Если это возможно удаленно, проведите простое модульное тестирование, которое может съесть каждое животное, которое вы поддерживаете, в виде:

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

должно быть очень простым и достаточным, и вы сможете определить, когда объекты не соответствуют их контракту.

Дуг Т.
источник
Интересно, действительно ли этот вопрос сводится к предпочтению тестирования черного ящика по сравнению с тестом белого ящика. Я склоняюсь к лагерю «черного ящика», и, возможно, поэтому я проверяю, как я. Спасибо что подметил это.
Скотт Уитлок
1

Ты делаешь это правильно. Думайте о модульном тесте как о тесте поведения единственного использования вашего нового кода. Это тот же вызов, который вы сделали бы из производственного кода.

В этой ситуации вы совершенно правы; пользователь Lion или Tiger не будет (по крайней мере, не обязан) заботиться о том, что они оба HerbivoreEaters и что код, который фактически выполняется для метода, является общим для обоих в базовом классе. Точно так же, пользователю реферата HerbivoreEater (предоставляемого конкретным Львом или Тигром) не важно, какой он имеет. Их волнует, что их Lion, Tiger или неизвестная конкретная реализация HerbivoreEater будут правильно есть () Herbivore.

Итак, что вы в основном тестируете, так это то, что Лев будет есть как задумано, а тигр будет есть как задумано Важно проверить оба, потому что не всегда может быть правдой, что они едят одинаково; проверяя оба, вы гарантируете, что тот, который вы не хотели менять, не сделал. Поскольку оба они определены как HerbivoreEaters, по крайней мере до тех пор, пока вы не добавите Cheetah, вы также проверили, что все HerbivoreEaters будут питаться, как предполагалось. Ваше тестирование полностью охватывает и адекватно применяет код (при условии, что вы также сделаете все ожидаемые утверждения о том, что должно произойти из-за того, что HerbivoreEater съел травоядное животное).

Keiths
источник