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

Введение

В течение 20 лет я разрабатывал приложения на Java. За эти годы я приобрёл большой опыт написания тестов (модульных / интеграционных тестов). Я использовал разные стили написания. Некоторые стили поначалу казались многообещающими, но оказались ужасными, например, когда нужно было добавлять новые функции к существующим классам. Часто причиной того, что тесты становились ужасными, был большой набор данных, требуемый для теста. Пара техник, опробованных моими товарищами по команде и мной, помогли нам написать чистые тесты, в которых использовалось много данных. В этой статье я хочу поделиться с вами этими техниками.

Основная цель теста – показать, что определённый фрагмент кода работает правильно. Тестируемый фрагмент кода называется тестируемым объектом.

Тест состоит из 3 частей:

  1. Подготовка: устанавливает тестовые данные
  2. Действие: вызывает код, который должен быть протестирован
  3. Проверка: проверяет, чтобы тестируемый объект работал правильно

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

Я представляю методы с примерами кода системы управления складом (WMS). Как вы можете найти на моём веб-сайте, я проработал на заводе по производству красок около 8 лет и работал над WMS, которая позже переросла в ERP. Это приложение было разработано на Java.

В этой статье я показываю примеры кода на Python с использованием WMS, реализованной в Django.

Примеры кода, которые я вам покажу, взяты не из реальной WMS, над которой я работал раньше. Тем не менее, они реалистичны и достаточно сложны, чтобы объяснить методы написания тестов, для которых требуется много данных. Вот почему я говорю: эта статья основана на реальной истории.

Что такое система управления складом?

Чтобы объяснить, что такое система управления складом (WMS), я сначала объясню, что такое склад. Склад – это большое здание, полное стеллажей. На стеллажах хранятся поддоны. В каждом поддоне находятся предметы, например, 200 банок белой краски по 1 литру каждая.

Следующие фотографии дают представление о здании, стеллажах и поддонах:

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

Операторы управляют вилочными погрузчиками для перемещения поддонов и подбора заказов.

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

Назначение склада – обрабатывать заказы. Точно так же, как архитектура программного обеспечения заключается в разделении функциональности на компоненты, хранилище может быть разделено на области с их собственным назначением:

Как писать тесты, которым требуется много данных?
  • Bulk: содержит полные поддоны, которые будут перемещены в область выбора, если место в области выбора пусто.
  • Pick: в этой области операторы выбирают товары, которые были заказаны клиентами.
  • Wrap: поддоны упаковывают в термоусадочную пленку, чтобы предметы не упали во время транспортировки.
  • Staging: поддоны группируются для каждого грузовика или прицепа в зоне размещения. Здесь выполняется окончательная проверка перед погрузкой поддонов в грузовик или прицеп.

У всего на складе есть идентификатор:

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

Вот пример штрих-кода и текст идентификатора местоположения в области выбора:

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

А вот пример штрих-кода и текста идентификатора поддона:

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

WMS поддерживает планировщиков и операторов в выполнении заказов. Это достигается за счёт предоставления планировщикам и операторам обзора текущих запасов и поддержки рабочих процессов на складе. Основными рабочими процессами в WMS являются:

  • складирование: поддоны с товарами производятся на заводе. Далее их доставляют на склад. Размещение этих поддонов в местах для хранения сыпучих материалов называется складированием.
  • комплектация: клиенты заказывают товары. Комплектация – это рабочий процесс получения заказанных товаров из мест в зоне комплектации и размещения их на поддонах.
  • ревизия: для обнаружения и устранения ошибок, допущенных во время комплектации, физическое содержимое поддонов для клиента сравнивается с содержимым поддонов, зарегистрированным WMS. Рабочий процесс этого сравнения называется ревизией. Разногласия должны быть разрешены. Результатом является то, что физическое содержимое соответствует зарегистрированному содержимому.
  • пополнение: количество товара постепенно уменьшается. Пополнение – это рабочий процесс, при котором поддоны из партии товара перемещаются в свободные места в зоне сбора.
  • погрузка: чтобы отправить поддоны своим клиентам, их сначала нужно завернуть, получить этикетку для доставки и, наконец, погрузить в грузовик или прицеп. Уведомление о перевозке (бумажное или EDI) генерируется, когда грузовик или прицеп отправляется. Все эти шаги вместе образуют рабочий процесс.

Сколько данных в тесте?

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

class TestStack(TestCase):
    stack = Stack()

    def test_empty_stack_is_empty(self):
        self.assertTrue(self.stack.is_empty())

    def test_empty_stack_count_returns_zero(self):
        self.assertEqual(self.stack.count(), 0)

    def test_pop_from_empty_stack_raises_exception(self):
        self.assertRaises(StackIsEmptyError, self.stack.pop)

    def test_stack_with_one_item_is_not_empty(self):
        self.stack.push("foo")

        self.assertFalse(self.stack.is_empty())

    def test_stack_with_one_item_has_count_one(self):
        self.stack.push("foo")

        self.assertEqual(self.stack.count(), 1)

    def test_pop_from_stack_with_one_item_returns_item(self):
        self.stack.push("foo")

        self.assertEqual(self.stack.pop(), "foo")

    def test_multiple_items_on_stack_are_popped_in_reverse_order_of_push(self):
        self.stack.push("foo")
        self.stack.push("bar")

        self.assertEqual(self.stack.pop(), "bar")
        self.assertEqual(self.stack.pop(), "foo")

В этих тестах используются не более двух элементов данных: строки foo и bar.

Большинство разработчиков работают над приложениями, которые хранят данные в базе данных. Со временем база данных будет состоять из десятков или сотен таблиц. Внедрение новых функций может привести к появлению новой таблицы или добавлению нескольких столбцов в существующую таблицу. Для тестирования новой функции требуются данные в таблицах. Если вам повезёт, вашему тесту нужны данные только из одной или двух таблиц. Если не повезёт, вам понадобятся данные из десятков разных таблиц или сотни записей в одной таблице.

Теперь мы возвращаемся к написанию тестов для WMS. В зависимости от тестируемого объекта необходимо создавать разные местоположения. Например, для тестирования склада необходима относительно большая площадь для хранения, например, в 100 местах. Для тестирования пополнения может быть достаточно небольшого объёма и зоны сбора. Для подбора необходимо использовать относительно большую область. Смотрите следующую таблицу, чтобы получить представление о количестве местоположений, необходимых для тестирования процесса. Обратите внимание, что помимо этого, нам всегда нужны элементы, пользователи и хотя бы один погрузчик.

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

Варианты настройки данных

Для тестируемого не имеет значения, как были настроены данные, главное, чтобы данные присутствовали. Я видел различные способы настройки тестовых данных в базе данных:

Какие есть варианты заполнения базы данных?

  1. Заполните базу данных для каждого тестового примера
  2. Заполните базу данных только один раз и используйте эту базу данных для всех тестовых случаев

Как заполнить базу данных?

  1. Заполните базу данных с помощью инструкций SQL
  2. Заполните базу данных с помощью кода Python

Что хранится в базе данных?

  1. Минимальный набор данных, необходимых тестируемому объекту. Данные могут быть неполными и нереалистичными.
  2. Полные и реалистичные данные. Их может быть больше, чем требуется испытуемому.

Представьте, что мы хотим протестировать перемещение поддона из одного места в другое. Сначала нам нужно создать местоположения в базе данных. Вот модель Django для местоположений:

class LocationType(Enum):
    BULK = "B"
    PICK = "P"
    PICK_AND_DROP = "&"
    AUDIT = "A"
    STAGING = "S"
    DOCK = "D"
    PROBLEM = "!"
    FORKLIFT = "F"


class Location(models.Model):
    id = models.CharField(max_length=8, primary_key=True)
    type = models.CharField(max_length=1, choices=[(tag, tag.value) for tag in LocationType])
    sequence = models.SmallIntegerField()
    level = models.SmallIntegerField()
    aisle = models.CharField(max_length=8, null=True, blank=True)
    blocked = models.BooleanField()

Вот инструкция SQL, которая может быть использована для создания местоположения в базе данных как часть упорядочивания тестового примера:

INSERT INTO location (id, type, sequence, level, aisle, blocked) 
VALUES ('BF145C', 'B', 543, 3, 'BULK-BF', false);

Вместо использования инструкций SQL я видел варианты, в которых данные копировались из производственной или тестовой базы данных, а затем хранились в виде XML-файлов. Это ещё один способ представления SQL-инструкций, и я считаю их такими же плохими как инструкции SQL:

<testdata>
  <location id="BF145C" type="B" sequence="543" level="3" aisle="BULK-BF" blocked="false" />
</testdata>

Django позволяет очень легко создать объект, используя код Python:

location = Location.objects.create(
    id="BF145C", type=LocationType.BULK, aisle="BULK-BF", sequence=543, level=3, blocked=False
)

Представьте, что мы добавляем новый столбец, переименовываем столбец или таблицу. Если вы используете инструкции SQL, вам необходимо обновить множество других инструкций. Если вы используете код на Python, как указано выше, вам пришлось бы обновить много кода на Python. Остальная часть этого поста описывает методы, позволяющие сохранить вашу часть упорядочивания короткой, чистой и читаемой, даже когда вам нужно для настройки большое количество данных. Эти методы значительно облегчат работу с изменениями в структуре базы данных.

Техника 1: Построение тестовых данных

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

Одна из особенностей нашей WMS заключается в том, что при перемещении поддона в проблемное место он блокируется. Проблемное местоположение – это местоположение с типом Location.PROBLEM.

Вот тестовый пример, иллюстрирующий этот сценарий:

from django.test import TestCase

from wms.models import Item, ItemOnPallet, Location, LocationType, Pallet
from wms.service import Service
from wms.tests import test_data_builder as tdb


class TestPalletMove(TestCase):
    service = Service()

    def test_pallet_moved_to_problem_location_gets_blocked(self):
        forklift = Location.objects.create(
            id="FORKLIFT01", sequence=0, level=0, type=LocationType.FORKLIFT, blocked=False
        )
        problem_location = Location.objects.create(
            id="PROBLEM", sequence=0, level=0, type=LocationType.PROBLEM, blocked=False
        )
        item = Item.objects.create(description="White paint (1 liter)")
        pallet = Pallet.objects.create(location=forklift, blocked=False)
        pallet.items.add(ItemOnPallet.objects.create(pallet=pallet, item=item, quantity=100, batch="2019401234"))

        self.service.move_pallet(pallet, problem_location)

        self.assertTrue(pallet.blocked)
        self.assertEqual(pallet.location, problem_location)

Часть упорядочивания – это самая большая часть теста. Для создания местоположения требуется заполнение множества параметров, большинство из которых даже не имеют отношения к тесту.

Тест становится более читаемым благодаря определению значений по умолчанию для sequence и level. Но иногда нежелательно использовать значения по умолчанию. Есть способ улучшить читаемость без использования значений по умолчанию в моделях: извлеките код для создания погрузчика, проблемного местоположения и поддона, чтобы разделить методы:

def test_pallet_moved_to_problem_location_gets_blocked(self):
    forklift = self.create_forklift()
    problem_location = self.create_problem_location()
    item = self.create_white_paint()
    pallet = self.create_pallet(forklift, {item: 100})

    self.service.move_pallet(pallet, problem_location)

    self.assertTrue(pallet.blocked)
    self.assertEqual(pallet.location, problem_location)

def create_forklift(self):
    return Location.objects.create(id="FORKLIFT01", sequence=0, level=0, type=LocationType.FORKLIFT, blocked=False)

def create_problem_location(self):
    return Location.objects.create(id="PROBLEM", sequence=0, level=0, type=LocationType.PROBLEM, blocked=False)

def create_white_paint(self):
    return Item.objects.create(description="White paint (1 liter)")

def create_pallet(self, forklift, items):
    pallet = Pallet.objects.create(location=forklift, blocked=False)
    for item, quantity in items.items():
        pallet.items.add(
            ItemOnPallet.objects.create(pallet=pallet, item=item, quantity=quantity, batch="2019401234")
        )
    return pallet

Видите, как улучшилась упорядочивающая часть тестового примера? Видите, как становится понятным намерение части упорядочивания?

Большинство переменных в части упорядочивания можно было бы встроить, чтобы сделать часть упорядочивания ещё меньше. Единственными переменными, которые нельзя встроить, являются problem_location и pallet, поскольку они используются более одного раза. Но что, если мы изменили методы create на методы, которые получают или создают объект? Например, метод create_problem_location() можно изменить на метод problem_location(), который создаст проблемное местоположение при первом вызове и вернёт то же проблемное местоположение при всех последующих вызовах. Мы можем применить эту же технику и к другим методам создания, за исключением create_pallet(). Причина сохранения create_pallet() как есть заключается в том, что для большинства тестовых случаев требуются поддоны с определённым содержимым или в определённых местах, а иногда требуется несколько поддонов. Использовать метод создания или получения поддонов просто не удобно.

Тестовый код теперь выглядит следующим образом:

def test_pallet_moved_to_problem_location_gets_blocked(self):
    pallet = self.create_pallet(self.forklift(), {self.white_paint(): 100})

    self.service.move_pallet(pallet, self.problem_location())

    self.assertTrue(pallet.blocked)
    self.assertEqual(pallet.location, self.problem_location())

Вау, часть упорядочивания теперь состоит всего из одной строки! И обратите внимание, насколько чётко выделяются важные значения: мы создаём поддон на вилочном погрузчике, а затем перемещаем его в проблемное место. На поддоне 100 банок белой краски.

Этот метод извлечения и использование трюка “создать или получить” действительно стоит затраченных усилий. Вы можете реализовать его в каждом тестовом классе. Но должен ли каждый класс получать методы create_pallet()white_paint() и forklift()? Это было бы нарушением принципа DRY. Итак, следующий шаг – перенести эти методы в отдельный модуль. Я даю название этому модулю test_data_builder.

В этом модуле методы сгруппированы в классы, по одному классу на класс модели. Чтобы классы в модуле test_data_builder выделялись на фоне классов модели, они имеют префикс Tdb. Итак, все методы, которые получают или создают Pallet, сгруппированы в класс TdbPallet. И вместо того, чтобы писать TdbPallet.createPallet(), я пишу TdbPallet.create(), чтобы избежать дублирования слова Pallet и сделать строки короче.

Преимущество введения класса TdbLocation.forklift() вместо создания функции forklift() в модуле test_data_builder заключается в том, что легче определить, какие местоположения могут быть созданы. Просто проверьте класс TdbLocation или тип TdbLocation в IDE и используйте автозаполнение, чтобы узнать, какие местоположения можно создать.

Результат переноса методов в этот модуль выглядит следующим образом:

from wms.tests.test_data_builder import TdbItem, TdbLocation, TdbPallet

def test_pallet_moved_to_problem_location_gets_blocked(self):
    pallet = TdbPallet.create(TdbLocation.forklift(), items={TdbItem.white_paint(): 100})

    self.service.move_pallet(pallet, TdbLocation.problem())

    self.assertTrue(pallet.blocked)
    self.assertEqual(pallet.location, TdbLocation.problem())

Вот код из модуля test_data_builder:

from datetime import date

from wms.models import AssignedLocation, Customer, Item, ItemOnPallet, Location, LocationType, Order, OrderLine, Pallet


class TdbItem:
    @staticmethod
    def white_paint():
        return Item.objects.get_or_create(description="White paint (1 liter)")[0]

    @staticmethod
    def black_paint():
        return Item.objects.get_or_create(description="Black paint (1 liter)")[0]

    @staticmethod
    def yellow_paint():
        return Item.objects.get_or_create(description="Yellow paint (1 liter)")[0]


class TdbLocation:

    _next_sequence = 123

    @staticmethod
    def forklift(id="FORKLIFT01"):
        return Location.objects.get_or_create(id=id, sequence=0, level=0, type=LocationType.FORKLIFT, blocked=False)[0]

    @staticmethod
    def problem(id="PROBLEM"):
        return Location.objects.get_or_create(id=id, sequence=0, level=0, type=LocationType.PROBLEM, blocked=False)[0]

    @staticmethod
    def audit(id="AUDIT", blocked=False):
        return Location.objects.get_or_create(
            id=id, aisle=None, sequence=0, level=0, type=LocationType.AUDIT, blocked=blocked
        )[0]

    @staticmethod
    def bulk(id=None, aisle="BULK", sequence=None, level=0, blocked=False):
        sequence = TdbLocation._get_sequence(sequence)
        return Location.objects.get_or_create(
            id=id or f"{aisle}{sequence:03d}{chr(65 + level)}",
            aisle=aisle,
            sequence=sequence,
            level=level,
            type=LocationType.BULK,
            blocked=blocked,
        )[0]

    @staticmethod
    def create_pick(id=None, aisle="PICK", sequence=None, level=0, blocked=False, item=None, max_quantity=100):
        sequence = TdbLocation._get_sequence(sequence)
        location = Location.objects.create(
            id=id or f"{aisle}{sequence:03d}{chr(65 + level)}",
            aisle=aisle,
            sequence=sequence,
            level=level,
            type=LocationType.PICK,
            blocked=blocked,
        )

        AssignedLocation.objects.create(
            location=location, item=item or TdbItem.white_paint(), max_quantity=max_quantity or 100
        )

        return location

    @staticmethod
    def staging(id=None, aisle="STAGING", sequence=0, level=0, blocked=False):
        sequence = TdbLocation._get_sequence(sequence)
        return Location.objects.get_or_create(
            id=id or f"{aisle}{sequence:03d}{chr(65 + level)}",
            aisle=aisle,
            sequence=sequence,
            level=level,
            type=LocationType.STAGING,
            blocked=blocked,
        )[0]

    @staticmethod
    def dock(id="DOCK01", blocked=False):
        return Location.objects.get_or_create(
            id=id, aisle=None, sequence=0, level=0, type=LocationType.DOCK, blocked=blocked
        )[0]

    @staticmethod
    def _get_sequence(sequence=None):
        if not sequence:
            sequence = TdbLocation._next_sequence
            TdbLocation._next_sequence += 1
        return sequence


class TdbPallet:

    _next_batch = 1

    @staticmethod
    def create(location: Location, items=None, blocked=False, pick_list=None):
        """Creates a pallet.

        :param location: the location where the pallet is created.
        :param items: a dictionary of items and quantities. Use None to create an empty pallet.
        :param blocked: indicates whether the pallet is blocked.
        :param pick_list: indicates whether the pallet contains or will contain items picked for a pick list.
        """
        pallet = Pallet.objects.create(location=location, blocked=blocked, pick_list=pick_list)

        for item, quantity in (items or {}).items():
            batch = f"{TdbPallet._next_batch:06d}"
            TdbPallet._next_batch += 1
            pallet.items.add(ItemOnPallet.objects.create(pallet=pallet, item=item, quantity=quantity, batch=batch))

        return pallet


class TdbCustomer:
    @staticmethod
    def create(name="John Doe") -> Customer:
        return Customer.objects.create(name=name)


class TdbOrder:
    @staticmethod
    def create(items, customer=None, shipping_date=None):
        order = Order.objects.create(
            customer=customer or TdbCustomer.create(), shipping_date=shipping_date or date.today()
        )

        for item, quantity in items.items():
            order.lines.add(OrderLine.objects.create(order=order, item=item, quantity=quantity))

        return order

Посмотрите, как некоторые функции создают что-то и возвращают то же самое в следующий раз, когда вызывается функция. Это полезно для вещей, которые постоянны в ваших тестах. TdbItem.white_paint()всегда возвращает один и тот же товар. Тестовые данные Builder упрощают использование небольшого набора предопределённых элементов.

Другие функции создают что-то новое каждый раз, когда вы их вызываете. Поддоны обычно создаются специально для теста, например, функции TdbPallet.create().

Код в построителе тестовых данных может стать немного сложным, как показано в TdbPallet.create(). Обратите внимание, что обычно вы пишете методы в построителе тестовых данных один раз и модифицируете / расширяете его пару раз, но используете их сотни раз. Итак, при написании функций для Test Data Builder сосредоточьтесь на простоте использования и чистоте тестового кода.

Методы в сборщиках тестовых данных обладают следующими свойствами:

  • Они просты в использовании.
  • Они имеют правильные и реалистичные значения по умолчанию.
  • Они позволяют переопределять значения, если это необходимо для теста.

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

При использовании построителя тестовых данных тестовый код должен быть простым и читаемым. Однако тесты не должны зависеть от конкретных значений по умолчанию.

И последний совет: вы также можете использовать конструктор тестовых данных для тестирования REST API:

class TdbLocation:

    @staticmethod
    def forklift(id="FORKLIFT01"):
        return {"id": id, "sequence": 0, "level": 0, "type": LocationType.FORKLIFT, "blocked": False}


def test_create_location(client):
    response = client.post('/locations', data=TdbLocation.forklift())
    assert response.status_code == 201

Техника 2: Визуализация установочных данных

Вторая техника иллюстрируется тестированием важной функции хранения. Помните, что складирование – это процесс размещения полных поддонов, которые только что прибыли с завода, в местах, расположенных в зоне массовых поставок. На схеме ниже показан вид сбоку прохода в зоне массового хранения. WMS сообщит водителю погрузчика, какое место следует заполнить следующим поддоном.

Заполнять проход уровень за уровнем снизу доверху неэффективно, поскольку расстояние по горизонтали, которое должен преодолевать погрузчик, значительно увеличивается. В реальной жизни существует более 5 локаций на одном уровне. Заполнять проход колонка за колонкой слева направо более эффективно. Однако погрузчики перемещаются быстрее по горизонтали, чем по вертикали. Итак, оптимальный способ заполнить проход – под углом.

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

Вот код для проверки того, что возвращено правильное местоположение для размещения поддона в пустом проходе:

def test_stocking_pallet_in_empty_aisle(self):
    pallet = TdbPallet.create(TdbLocation.forklift(), items={(TdbItem.white_paint()): 100})
    for sequence in range(1, 6):
        for level in range(0, 5):
            TdbLocation.bulk(aisle=self.AISLE, sequence=sequence, level=level)

    destination = self.service.get_stock_location(pallet, self.AISLE)

    self.assertEqual(destination, TdbLocation.bulk(aisle=self.AISLE, sequence=1, level=0))

Посмотрите, как с помощью конструктора тестовых данных во вложенных циклах создать пустой проход в всего 3 строки кода. Этот способ генерации данных становится беспорядочным, когда определённые места в проходе уже заняты поддонами.

Приведённая выше диаграмма вдохновляет меня на текстовое представление прохода в виде многострочной строки. Например, следующая строка представляет собой проход, содержащий 3 поддона, а звёздочка указывает место, где должен быть размещён следующий поддон:

 """|     |
    |     |
    |o    |
    |oo*  |"""

Эта многострочная строка определяет как упорядочивающую, так и утверждающую части теста!

Вот код, который использует такие текстовые представления для указания прохода, который должен быть сконфигурирован, и указывает предполагаемое местоположение, где должен быть размещён следующий поддон:

@parameterized.expand(
    [
        (
            "empty aisle",
            """|     |
               |     |
               |     |
               |*    |""",
        ),
        (
            "one pallet present",
            """|     |
               |     |
               |     |
               |o*   |""",
        ),
        (
            "two pallets present",
            """|     |
               |     |
               |*    |
               |oo   |""",
        ),
        (
            "three pallets present",
            """|     |
               |     |
               |o    |
               |oo*  |""",
        ),
        (
            "four pallets present",
            """|     |
               |     |
               |o*   |
               |ooo  |""",
        ),
        (
            "no free location in aisle",
            """|ooooo|
               |ooooo|
               |ooooo|
               |ooooo|""",
        ),
        (
            "first gap is filled",
            """|     |
               |     |
               | o   |
               |o*o  |""",
        ),
        (
            "blocked location is skipped",
            """|     |
               |     |
               |     |
               |x*   |""",
        ),
    ]
)
def test_stocking_pallet(self, _, bulk_aisle_map: str) -> None:
    """
    Test if a non-blocked pallet gets the correct stock location within an aisle.
    :param _: is added to the name of the test. Not used otherwise.
    :param bulk_aisle_map: two-dimensional map of the aisle. The pipes indicate the start and end of a level
    in the rack. The meaning of the characters between the pipes is:
    o: a pallet
    *: empty location, this is the expected location
    x: empty location, blocked
    """
    expected_location = self._create_locations_in_aisle(bulk_aisle_map)
    pallet = TdbPallet.create(TdbLocation.forklift(), items={TdbItem.white_paint(): 100})

    destination = self.service.get_stock_location(pallet, self.AISLE)

    if expected_location:
        self.assertEqual(destination, expected_location)
    else:
        self.assertIsNone(destination)

def _create_locations_in_aisle(self, bulk_aisle_map):
    lines = re.findall("[|][^|]+[|]", bulk_aisle_map)
    lines.reverse()
    expected_location = None
    level = 0
    for line in lines:
        for sequence in range(1, len(line) - 1):
            bulk_location = TdbLocation.bulk(aisle=self.AISLE, sequence=sequence, level=level)
            if line[sequence] == "o":
                items = {TdbItem.white_paint(): 100}
                TdbPallet.create(bulk_location, items=items)
            if line[sequence] == "*":
                expected_location = bulk_location
            if line[sequence] == "x":
                bulk_location.blocked = True
                bulk_location.save()
        level += 1
    return expected_location

Реализована логика синтаксического анализа многострочной строки и генерации данных  _create_locations_in_aisle(). Этот код немного сложный, но как только этот метод был написан, генерация тестовых примеров становится проще простого. Вы можете объединить программу со своим владельцем продукта, чтобы написать больше тестовых примеров.

Другая ситуация, в которой этот метод может быть применён – когда вы тестируете код на основе участника. Вы могли бы использовать одну строку для каждого участника, чтобы указать, в какой момент времени ему отправляется конкретное сообщение. Другая строка может быть использована для описания ожидаемых сообщений, отправляемых конкретным субъектом:

in_1: "1        4          3     "
in_2: "  a        b      c       "
out:  "   (a,1)    (b,4)    (c,3)"

Техника 3: Рабочий процесс

Третий метод иллюстрирует тестирование частей рабочего процесса. Следующая диаграмма описывает рабочий процесс для обработки заказов:

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

Шаг “Operator picks pick list” может быть дополнительно детализирован следующим образом:

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

WMS реализует следующие методы, которые используются терминалом в процессе комплектации:

  • get_next_pick_location(pick_list, current_location): возвращает местоположение, где должны быть выбраны следующие элементы, с учётом текущего местоположения погрузчика.
  • get_pick_quantity_for(pick_list, location): возвращает количество предметов, которые нужно выбрать в заданном месте.

Чтобы должным образом протестировать эти методы, нам нужен заказ, список комплектации и поддон на погрузчике, содержащий уже отобранные товары. Есть много сценариев, которые мы хотим протестировать:

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

Чем больше выбранных элементов требуется для упорядочивающей части теста, тем больше строк кода добавляется к упорядочивающей части. Это снижает читаемость и ремонтопригодность этих тестов.

Поскольку рабочий процесс выбора чётко определён, довольно легко написать класс PickWorkflow, который будет использовать бизнес-логику для приведения базы данных в определённое состояние. Класс имеет методы, которые соответствуют шагам в рамках схем рабочего процесса, показанных выше. Каждый метод гарантирует, что все предыдущие шаги потока будут выполнены.

from typing import Optional

from wms.models import Location, LocationType, Pallet, PickList
from wms.service import Service
from wms.tests.test_data_builder import TdbLocation, TdbOrder, TdbPallet


class PickWorkflow:
    def __init__(self, items, customer=None, shipping_date=None, generate_pick_locations=True, forklift=None):
        self.service = Service()
        self.forklift = forklift or TdbLocation.forklift()
        self.order = TdbOrder.create(items, customer, shipping_date)
        self.pick_list: Optional[PickList] = None
        self.pick_pallet: Optional[Pallet] = None
        self.picked_pallets = []

        if generate_pick_locations:
            self._create_pick_locations()

    def _create_pick_locations(self):
        for order_line in self.order.lines.all():
            location = TdbLocation.create_pick(item=order_line.item, max_quantity=order_line.quantity)
            items = {order_line.item: order_line.quantity}
            TdbPallet.create(location, items=items)

    def generate_pick_list(self):
        if self.pick_list:
            raise Exception("A pick list has already been generated for the order.")

        self.pick_list = self.service.create_pick_list(self.order)

        return self

    def pick(self, items):
        self._ensure_pick_list_is_generated()
        self._ensure_pick_pallet_is_created()
        for item, quantity in items.items():
            location = self.service.get_next_pick_location(self.pick_list, None)
            self.service.pick(self.pick_list, location, self.pick_pallet, item, quantity)
        return self

    def put_pallet_in_staging_lane(self, staging_location: Optional[Location] = None):
        if not self.pick_pallet:
            raise Exception("The forklift carries no pallet.")

        staging_location = staging_location or TdbLocation.staging()
        if not staging_location:
            raise Exception("The warehouse has no staging location.")

        self.service.move_pallet(self.pick_pallet, staging_location)
        self.pick_pallet = None

        return self

    def pick_and_put_pallet_in_staging(self, staging_location: Optional[Location] = None):
        self._ensure_pick_list_is_generated()
        location = Location.objects.filter(type=LocationType.PICK).first()
        while not self.pick_list.is_completely_picked():
            self._ensure_pick_pallet_is_created()
            location = self.service.get_next_pick_location(self.pick_list, location)
            if location:
                quantity = self.service.get_pick_quantity_for(self.pick_list, location)
                self.service.pick(self.pick_list, location, self.pick_pallet, location.assignment.item, quantity)
            else:
                raise Exception("Not enough items on stock for pick list.")

        self.put_pallet_in_staging_lane(staging_location)

        return self

    def pick_and_put_pallet_in_truck(self, dock: Optional[Location] = None):
        self.pick_and_put_pallet_in_staging()

        dock = dock or Location.objects.filter(type=LocationType.DOCK).first()
        if not dock:
            raise Exception("The warehouse has no dock location.")

        for pallet in self.picked_pallets:
            if not pallet.shipping_label:
                self.service.print_shipping_label(pallet)
            self.service.move_pallet(pallet, dock)

        return self

    def _ensure_pick_list_is_generated(self):
        if not self.pick_list:
            self.generate_pick_list()

    def _ensure_pick_pallet_is_created(self):
        if not self.pick_pallet:
            self.pick_pallet = TdbPallet.create(self.forklift, pick_list=self.pick_list)
            self.picked_pallets.append(self.pick_pallet)

Вот примеры тестов, которые используют класс PickWorkflow:

import re

from django.test import TestCase

from wms.models import Location
from wms.service import Service
from wms.tests.pick_workflow import PickWorkflow
from wms.tests.test_data_builder import TdbItem, TdbLocation, TdbPallet


class TestPicking(TestCase):
    service = Service()

    def test_get_pick_quantity_when_nothing_picked_yet(self):
        self._create_pick_locations("P_1: 100xwhite_paint")
        workflow = PickWorkflow(generate_pick_locations=False, items={TdbItem.white_paint(): 5}).generate_pick_list()

        quantity = self._get_pick_quantity("P_1", workflow)

        self.assertEqual(quantity, 5)

    def test_get_pick_quantity_for_location_that_has_less_than_remaining_quantity_to_be_picked(self):
        self._create_pick_locations("P_1: 3xwhite_paint")
        workflow = PickWorkflow(generate_pick_locations=False, items={TdbItem.white_paint(): 5}).generate_pick_list()

        quantity = self._get_pick_quantity("P_1", workflow)

        self.assertEqual(quantity, 3)

    def test_get_pick_quantity_for_empty_location(self):
        self._create_pick_locations("P_1: 0xwhite_paint")
        workflow = PickWorkflow(generate_pick_locations=False, items={TdbItem.white_paint(): 5}).generate_pick_list()

        quantity = self._get_pick_quantity("P_1", workflow)

        self.assertEqual(quantity, 0)

    def test_pick_part_of_pick_list_get_pick_quantity_for_item(self):
        self._create_pick_locations("P_1: 100xwhite_paint")
        workflow = PickWorkflow(generate_pick_locations=False, items={TdbItem.white_paint(): 5}).pick(
            items={TdbItem.white_paint(): 2}
        )

        quantity = self._get_pick_quantity("P_1", workflow)

        self.assertEqual(quantity, 3)

    def test_pick_complete_pick_list_get_pick_quantity_for_item(self):
        self._create_pick_locations("P_1: 100xwhite_paint")
        workflow = PickWorkflow(generate_pick_locations=False, items={TdbItem.white_paint(): 5}).pick(
            items={TdbItem.white_paint(): 5}
        )

        quantity = self._get_pick_quantity("P_1", workflow)

        self.assertEqual(quantity, 0)

    def test_pick_item_1_completely_get_pick_quantity_for_item_2(self):
        self._create_pick_locations("P_1: 100xwhite_paint", "P_2: 100xblack_paint")
        workflow = PickWorkflow(
            generate_pick_locations=False, items={TdbItem.white_paint(): 5, TdbItem.black_paint(): 10}
        ).pick(items={TdbItem.white_paint(): 5})

        quantity = self._get_pick_quantity("P_2", workflow)

        self.assertEqual(quantity, 10)

    def test_pick_item_1_partly_get_pick_quantity_for_item_2(self):
        self._create_pick_locations("P_1: 100xwhite_paint", "P_2: 100xblack_paint")
        workflow = PickWorkflow(
            generate_pick_locations=False, items={TdbItem.white_paint(): 5, TdbItem.black_paint(): 10}
        ).pick(items={TdbItem.white_paint(): 3})

        quantity = self._get_pick_quantity("P_2", workflow)

        self.assertEqual(quantity, 10)

    def test_get_pick_quantity_for_item_that_has_been_picked_partly_on_other_pallet(self):
        self._create_pick_locations("P_1: 100xwhite_paint")
        workflow = (
            PickWorkflow(generate_pick_locations=False, items={TdbItem.white_paint(): 5})
            .pick(items={TdbItem.white_paint(): 3})
            .put_pallet_in_staging_lane()
        )

        quantity = self._get_pick_quantity("P_1", workflow)

        self.assertEqual(quantity, 2)

    def _get_pick_quantity(self, location_id, workflow):
        location = Location.objects.get(id=location_id)
        return self.service.get_pick_quantity_for(workflow.pick_list, location)

    def _create_pick_locations(self, *args):
        for arg in args:
            match = re.match("(.+): ([0-9]+)x(.+)", arg)
            self.assertIsNotNone(match, f"The argument {arg} is invalid!")
            item = getattr(TdbItem, match.group(3))()
            location = TdbLocation.create_pick(id=match.group(1), item=item)
            items = {item: int(match.group(2))}
            TdbPallet.create(location, items=items)

PickWorkflow удобен не только для тестовой комплектации. После загрузки поддонов в грузовик транспортная документация может быть сгенерирована. Одним из видов транспортной документации является транспортное уведомление, в котором описываются поддоны и их содержимое в грузовике. Вот пример теста для генерации уведомления о транспортировке для трёх поддонов из трёх разные заказы:

def test_transport_notice(self):
    workflow_1 = PickWorkflow(items={TdbItem.white_paint(): 10}).pick_and_put_pallet_in_truck(TdbLocation.dock())
    workflow_2 = PickWorkflow(items={TdbItem.black_paint(): 25}).pick_and_put_pallet_in_truck(TdbLocation.dock())
    workflow_3 = PickWorkflow(items={TdbItem.yellow_paint(): 45}).pick_and_put_pallet_in_truck(TdbLocation.dock())

    transport_notice = self.service.generate_transport_notice(TdbLocation.dock())

    self.assertEqual(
        transport_notice,
        [
            {
                "shipping_label": workflow_1.picked_pallets[0].shipping_label,
                "contents": [{"item": TdbItem.white_paint().description, "quantity": 10}],
            },
            {
                "shipping_label": workflow_2.picked_pallets[0].shipping_label,
                "contents": [{"item": TdbItem.black_paint().description, "quantity": 25}],
            },
            {
                "shipping_label": workflow_3.picked_pallets[0].shipping_label,
                "contents": [{"item": TdbItem.yellow_paint().description, "quantity": 45}],
            },
        ],
    )

Сосредоточьтесь на чистом тестовом коде

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

WMS проверит каждый поддон, который будет загружен в грузовик. Если для поддона зарегистрирован результат, который указывает, что была обнаружена разница между фактическим содержимым на поддоне и содержимым в соответствии с WMS, поддон не разрешается загружать. Идея в том, что сначала необходимо устранить разницу, прежде чем поддон можно будет загрузить.

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

Представьте, что вы хотите извлечь из этого теста две строки, вызывающие move_pallet() и register_audit_result():

def test_move_pallet_to_dock_with_differences_found_during_audit(self):
    pallet = TdbPallet.create(TdbLocation.bulk())
    service.move_pallet(pallet.id, TdbLocation.audit().id)
    service.register_audit_result(pallet.id, True)

    self.assertRaises(Exception, self.service.move_pallet, pallet.id, TdbLocation.dock().id)

Не стоит делать это так:

def test_move_pallet_to_dock_with_differences_found_during_audit(self):
    pallet = TdbPallet.create(TdbLocation.bulk())
    self.audit_pallet_with_differences_found(pallet.id, TdbLocation.audit().id)

    self.assertRaises(Exception, self.service.move_pallet, pallet.id, TdbLocation.dock().id)

Лучше воспользоваться данных способом:

def test_move_pallet_to_dock_with_differences_found_during_audit(self):
    pallet = TdbPallet.create(TdbLocation.bulk())
    self.audit_pallet_with_differences_found(pallet, TdbLocation.audit())

    self.assertRaises(Exception, self.service.move_pallet, pallet.id, TdbLocation.dock().id)

Пусть извлечённый метод заботится об идентификаторах поддона и стыковки. Содержите тестовый код в чистоте!

Выводы

Были объяснены три техники генерации тестовых данных:

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

Используя эти методы, можно настроить базу данных

  • для каждого тестового примера
  • с полными и реалистичными данными
  • с помощью нескольких строк кода

При использовании этих методов сосредоточьтесь на простоте использования и удобочитаемости тестов.

+1
0
+1
0
+1
0
+1
0
+1
0

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *