Статьи

Создание подделок в Go с каналами

Написанный  Ником Готье в Codeship .

Подделки — это распространенная методика тестирования, которая заключается в создании простой реализации интерфейса, который можно использовать при тестировании. Обычно они позволяют вам проверить, как они использовались, чтобы вы могли убедиться в поведении тестируемого объекта. Обычно у подделок есть методы хранения и поиска, но в этом посте мы собираемся изучить использование каналов и программ Go для создания невероятных, универсальных подделок, которые также синхронизируют тестовый код с исполняемым кодом, даже когда исполняемый код является параллельным.

Единица

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

type GroceryList struct {
    Store API
}

func New() *GroceryList {
    return &GroceryList{&HTTPClient{}}
}

func (g *GroceryList) AddItem(item string) error {
    return g.Store.Create(&Note{Text: item})
}

func (g *GroceryList) Items() ([]string, error) {
    notes, err := g.Store.All()
    if err != nil {
        return []string{}, err
    }

    items := make([]string, len(notes))
    for i := range notes {
        items[i] = notes[i].Text
    }

    return items, nil
}

Довольно просто, он просто взаимодействует с Store и использует какую-то структуру Note (это из API, который мы рассмотрим в ближайшее время). Это часто происходит, когда вам нужно обернуть какой-то сторонний код, вы создаете свои собственные объекты, такие как Grocery List, которые должны соответствовать некоторым другим типам данных и методам служб. Но этот код должен выглядеть довольно просто. Также обратите внимание, что когда мы используем конструктор, мы используем некоторый HTTPClient по умолчанию. Это объект, который мы будем тестировать. Но прежде чем мы перейдем к тесту, давайте посмотрим на API, который мы будем использовать.

Сотрудник

Поскольку тестируемым модулем является Grocery List, любой другой код будет соавтором, и мы хотим изолировать Grocery List как можно лучше, поэтому мы будем использовать подделки для наших соавторов. Но сначала давайте посмотрим на API, Note и HTTPClient:

type API interface {
    Create(*Note) error
    All() ([]*Note, error)
}

type Note struct {
    Text string
}

type HTTPClient struct {
}

func (c *HTTPClient) Create(n *Note) error {
    // some implementation

    return nil
}

func (c *HTTPClient) All() ([]*Note, error) {
    // some implementation

    return []*Note{}, nil
}

В API просто есть метод Create и All, который работает с этими Notes, а Notes просто переносит некоторый текст. У нас также есть метод Create и All для HTTPClient. Представьте на мгновение, что этот код был передан вам другим отделом или другой компанией или службой или чем-то еще. У нас нет свободы изменять этот код, и мы хотим максимально изолировать его реализацию, когда будем тестировать список покупок. Для этого мы создадим FakeClient, который реализует API, и добавим его в наш список покупок, поэтому не имеет значения, что у нас сейчас даже нет реализации. И на самом деле, это действительно хорошо, потому что вы можете сначала написать свои объекты более высокого порядка, а затем вернуться и реализовать клиентов низкого уровня позже.

Внедрение подделки и утверждение

Давайте начнем с внедрения фальшивок и выполнения утверждений в фоновом режиме. Мы начнем с одного метода и реализуем его перед тем, как приступить ко второму. Во-первых, вот наш тест:

func TestGroceryList(t *testing.T) {
    client := NewFakeClient(t)
    list := New()
    list.Store = client

    go func() {
        client.AssertCreate(&Note{"apples"}, nil)
        client.Close()
    }()
    list.AddItem("apples")
    client.AssertDone(t)
}

Итак, мы создаем FakeClient и даем ему экземпляр тестирования, чтобы он мог делать утверждения. Затем мы добавляем его в список покупок. Это называется инъекцией сеттера. Далее, в процедуре мы будем вызывать AssertCreate и передавать то, что, как мы ожидаем, будет вызывать метод Create из Магазина: Note с «яблоками». Мы также передаем возвращаемое значение для отправки обратно в список покупок: нулевая ошибка. Затем мы закрываем клиента. Также обратите внимание, что в конце теста мы вызываем AssertDone. AssertDone просто будет ждать закрытия, чтобы наша программа не потерялась, а также будет следить за тем, чтобы не было никаких дополнительных вызовов. Итак, AssertCreate довольно крутой, потому что мы можем сказать, с чем мы ожидаем, что он будет вызван, плюс то, что вернуть вызывающей стороне. Это означает, что мы можем использовать это снова и снова, и нам не нужно хранить много данных вызовов на поддельном объекте.Мы просто вызываем метод каждый раз.

Время канала

Хорошо, давайте углубимся в реализацию FakeClient:

type Call interface{}

type FakeClient struct {
        t     *testing.T
        Calls chan Call
}

func NewFakeClient(t *testing.T) *FakeClient {
        return &FakeClient{t, make(chan Call)}
}

type createCall struct{ note *Note }
type createResp struct{ err error }

func (c *FakeClient) Create(n *Note) error {
        c.Calls <- &createCall{n}
        return (<-c.Calls).(*createResp).err
}

func (c *FakeClient) AssertCreate(n *Note, err error) {
        call := (<-c.Calls).(*createCall)
        if *call.note != *n {
                c.t.Error("expected create with", n, "but was", call.note)
        }
        c.Calls <- &createResp{err}
}

func (c *FakeClient) Close() {
        close(c.Calls)
}

func (c *FakeClient) AssertDone(t *testing.T) {
    if _, more := <-c.Calls; more {
        t.Fatal("Did not expect more calls")
    }
}

Хорошо, давайте пройдемся по этому. FakeClient имеет экземпляр тестирования и канал вызовов. Вызовы — это просто объекты интерфейса {}, поэтому мы можем отправить сюда что угодно. Конструктор NewFakeClient прост. Далее у нас есть объекты createCall и createResp. Эти структуры содержат параметры для Create и возвращаемое значение. Их поля должны совпадать с параметрами и возвращать значения точно. Теперь посмотрите на Create. Что мы делаем, мы отправляем createCall на канал вызовов с параметром. Затем мы получаем от канала вызовов a createResp, который мы возвращаем в качестве возвращаемого значения. Так что это Творение может получать и отвечать всем, что мы хотим. Нам не нужно хранить и воспроизводить звонки, мы будем использовать каналы! Прохладно! AssertCreate принимает и параметр, и возвращаемое значение, и он получает createCall от клиента,затем выполняет утверждение, что ожидаемый параметр — это то, что было вызвано. В этом случае мы хотим убедиться, что значения Note равны. Затем мы отправляем createResp, содержащий значение ошибки, обратно в фальшивый клиент. Наконец, у нас есть Close, который просто закрывает канал, и AssertDone, который гарантирует, что на канале ничего не осталось. Прокрутите назад и посмотрите на тест. Мы выполняем утверждения в одной процедуре, а клиент — в другой. Таким образом, они фактически синхронизируются между собой, поэтому, когда метод вызывается на клиенте, мы ДОЛЖНЫ иметь вызов assert, или мы получим тупик. На самом деле это действительно классная функция, потому что она означает, что все вызовы должны учитываться в том порядке, в котором они были вызваны. В конце теста мы также утверждаем, что впоследствии не пропустили ни одного звонка.В этом случае мы хотим убедиться, что значения Note равны. Затем мы отправляем createResp, содержащий значение ошибки, обратно в фальшивый клиент. Наконец, у нас есть Close, который просто закрывает канал, и AssertDone, который гарантирует, что на канале ничего не осталось. Прокрутите назад и посмотрите на тест. Мы выполняем утверждения в одной процедуре, а клиент — в другой. Таким образом, они фактически синхронизируются между собой, поэтому, когда метод вызывается на клиенте, мы ДОЛЖНЫ иметь вызов assert, или мы получим тупик. На самом деле это действительно классная функция, потому что она означает, что все вызовы должны учитываться в том порядке, в котором они были вызваны. В конце теста мы также утверждаем, что впоследствии не пропустили ни одного звонка.В этом случае мы хотим убедиться, что значения Note равны. Затем мы отправляем createResp, содержащий значение ошибки, обратно в фальшивый клиент. Наконец, у нас есть Close, который просто закрывает канал, и AssertDone, который гарантирует, что на канале ничего не осталось. Прокрутите назад и посмотрите на тест. Мы выполняем утверждения в одной процедуре, а клиент — в другой. Таким образом, они фактически синхронизируются между собой, поэтому, когда метод вызывается на клиенте, мы ДОЛЖНЫ иметь вызов assert, или мы получим тупик. На самом деле это действительно классная функция, потому что она означает, что все вызовы должны учитываться в том порядке, в котором они были вызваны. В конце теста мы также утверждаем, что впоследствии не пропустили ни одного звонка.у нас есть Close, который просто закрывает канал, и AssertDone, который гарантирует, что на канале ничего не осталось. Прокрутите назад и посмотрите на тест. Мы выполняем утверждения в одной процедуре, а клиент — в другой. Таким образом, они фактически синхронизируются между собой, поэтому, когда метод вызывается на клиенте, мы ДОЛЖНЫ иметь вызов assert, или мы получим тупик. На самом деле это действительно классная функция, потому что она означает, что все вызовы должны учитываться в том порядке, в котором они были вызваны. В конце теста мы также утверждаем, что впоследствии не пропустили ни одного звонка.у нас есть Close, который просто закрывает канал, и AssertDone, который гарантирует, что на канале ничего не осталось. Прокрутите назад и посмотрите на тест. Мы выполняем утверждения в одной процедуре, а клиент — в другой. Таким образом, они фактически синхронизируются между собой, поэтому, когда метод вызывается на клиенте, мы ДОЛЖНЫ иметь вызов assert, или мы получим тупик. На самом деле это действительно классная функция, потому что она означает, что все вызовы должны учитываться в том порядке, в котором они были вызваны. В конце теста мы также утверждаем, что впоследствии не пропустили ни одного звонка.поэтому, когда метод вызывается на клиенте, мы ДОЛЖНЫ иметь вызов assert, или мы получим тупик. На самом деле это действительно классная функция, потому что она означает, что все вызовы должны учитываться в том порядке, в котором они были вызваны. В конце теста мы также утверждаем, что впоследствии не пропустили ни одного звонка.поэтому, когда метод вызывается на клиенте, мы ДОЛЖНЫ иметь вызов assert, или мы получим тупик. На самом деле это действительно классная функция, потому что она означает, что все вызовы должны учитываться в том порядке, в котором они были вызваны. В конце теста мы также утверждаем, что впоследствии не пропустили ни одного звонка.

Повторение

Хорошо, теперь, когда мы понимаем, как создать элемент списка покупок и проверить его создание, давайте запустим тот же процесс, но для нашего другого метода Items (). Чтобы рассмотреть, давайте посмотрим на метод Items в Grocery List:

func (g *GroceryList) Items() ([]string, error) {
    notes, err := g.Store.All()
    if err != nil {
        return []string{}, err
    }

    items := make([]string, len(notes))
    for i := range notes {
        items[i] = notes[i].Text
    }

    return items, nil
}

Для тестирования Предметов нам нужно написать тест, чтобы покрыть это:

func TestGroceryListAll(t *testing.T) {
    client := NewFakeClient(t)
    list := New()
    list.Store = client

    go func() {
        client.AssertAll([]*Note{{"apples"}}, nil)
        client.Close()
    }()
    items, err := list.Items()
    if err != nil {
        t.Fatal(err)
    }
    if len(items) != 1 {
        t.Fatal("expected one item")
    }
    if items[0] != "apples" {
        t.Fatal("expected apples")
    }

    client.AssertDone(t)
}

ОК, это немного дольше. Мы делаем обычную настройку в начале, и у нас есть тот же вызов assert и close в нашей программе. Но мы добавили немного больше утверждений снаружи. Мы вызываем Предметы, но затем мы также проверяем, чтобы возвращаемые товары были массивом, содержащим одну строку «яблоки». Таким образом, мы можем убедиться, что Items правильно разбирает объекты Note на строки и возвращает их. На этом этапе реализация фейка точно такая же, как и предыдущая для Create, за исключением другой подписи и структур для параметров и возвращаемых значений:

type allCall struct{}
type allResp struct {
    notes []*Note
    err   error
}

func (c *FakeClient) All() ([]*Note, error) {
    c.Calls <- &allCall{}
    resp := (<-c.Calls).(*allResp)
    return resp.notes, resp.err
}

func (c *FakeClient) AssertAll(notes []*Note, err error) {
    call := (<-c.Calls).(*allCall)
    if call == nil {
        c.t.Error("No all call")
    }
    c.Calls <- &allResp{notes, err}
}

У нас есть структуры Call и Resp, соответствующие параметрам и возвращаемым значениям, как обычно. Вызов All почти одинаков, за исключением того, что нам нужно присвоить ответ переменной, чтобы мы могли получить доступ к множественным возвращаемым значениям. Тогда в нашем утверждении нет параметров для утверждения, поэтому мы можем просто убедиться, что вызов не равен нулю, и вернуть желаемые возвращаемые значения в канале. И это все! Наши тесты должны пройти.

Вывод

Итак, в заключение, мы создали простой, повторяемый шаблон, который создает подделки для любого интерфейса, который можно использовать во многих различных комбинациях без изменений. Это означает, что как только мы напишем фальшивый метод, мы сможем написать любые тесты, которые захотим, не модифицируя фальшивку. Это действительно красиво и гибко. Кроме того, синхронизация с использованием небуферизованных каналов означает, что наша реализация и тестирование выстроены в линию. Одна вещь, которую я действительно хотел бы улучшить, это создание подделок. Так как они зависят только от интерфейса, я бы хотел написать библиотеку Go Generate, которая могла бы создавать подделки для меня. Над этим я буду работать в будущем, так как , я уверен , это очень поможет в моей повседневной работе здесь, в Codeship .

Наконец, в качестве быстрого совета, я обнаружил, что комбинация клавиш Ctrl + \ неоценима, когда у меня запущена пара фальшивых и фоновых подпрограмм. Когда вы используете таймауты и тики, иногда вы можете оказаться в тупике, но на самом деле не вызывать тупик (например, потому что вы зацикливаетесь на выборе). Используя Ctrl + \, вы можете заставить тест прерваться и вывести обратную трассировку для каждой процедуры. Если бы у меня не было Ctrl + \, я бы сошел с ума!