Выразительные тесты без testify/assert

Многие Go-разработчики предпочитают ассерты (asserts, проверки в тестах) без if, чтобы тесты были короче и понятнее. Вместо того чтобы писать if с t.Errorf:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    age, err := db.Str().Get("age")
    if !errors.Is(err, redka.ErrNotFound) {
        t.Errorf("want ErrNotFound, got %v", err)
    }
    if age != nil {
        t.Errorf("want nil, got %v", age)
    }
}
PASS

Они используют пакет testify/assert (или его злодейского близнеца testify/require):

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    age, err := db.Str().Get("age")
    assert.ErrorIs(t, err, redka.ErrNotFound)
    assert.Nil(t, age)
}

Но я не думаю, что для хороших тестов действительно нужен testify/assert с его 40 разными ассертами. Расскажу о другом способе.

Пакет testify также содержит моки и тест-сьюты. Мы не будем их рассматривать — поговорим только про ассерты.

Проверка на равенство

Самая распространенная проверка в тестах — проверка на равенство:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    name, _ := db.Str().Get("name") // отложим пока проверку ошибок
    if name.String() != "alice" {
        t.Errorf("want 'alice', got '%v'", name)
    }
}
PASS

Давайте напишем простую дженерик-функцию для таких проверок:

// AssertEqual проверяет, что got равно want.
func AssertEqual[T any](tb testing.TB, got T, want T) {
    tb.Helper()

    // Проверяем, что оба равны nil.
    if isNil(got) && isNil(want) {
        return
    }

    // Иначе сравниваем через рефлексию.
    if reflect.DeepEqual(got, want) {
        return
    }

    // Нет совпадения, сообщаем о проблеме.
    tb.Errorf("want %#v, got %#v", want, got)
}

Приходится использовать вспомогательную функцию isNil, потому что компилятор не позволит сравнивать типизированное значение T с нетипизированным nil:

// isNil проверяет, что v равно nil.
func isNil(v any) bool {
    if v == nil {
        return true
    }

    // Интерфейс может быть не nil, но содержать nil,
    // поэтому проверяем внутреннее значение.
    rv := reflect.ValueOf(v)
    switch rv.Kind() {
    case reflect.Chan, reflect.Func, reflect.Interface,
        reflect.Map, reflect.Pointer, reflect.Slice,
        reflect.UnsafePointer:
        return rv.IsNil()
    default:
        return false
    }
}

Применим ассерт в нашем тесте:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    name, _ := db.Str().Get("name")
    AssertEqual(t, name.String(), "alice")
}
PASS

Порядок параметров в AssertEqual такой: (got, want), а не (want, got), как в testify. Так проще читается — ведь в жизни мы говорим «ее зовут Алиса», а не «Алиса — это ее имя».

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

AssertEqual уже отлично подходит для любых проверок на равенство — а это, по моему опыту, до 70% всех проверок в тестах. Неплохо для альтернативы testify размером в 20 строк! Но мы можем сделать ее еще лучше, так что давайте не упускать этот шанс.

Во-первых, типы вроде time.Time и net.IP предоставляют специальный метод Equal. Следует его использовать, чтобы сравнение было точным:

// equaler - интерфейс для типов с методом Equal
// (вроде time.Time или net.IP).
type equaler[T any] interface {
    Equal(T) bool
}

// areEqual проверяет, что a равно b.
func areEqual[T any](a, b T) bool {
    // Проверяем, что оба равны nil.
    if isNil(a) && isNil(b) {
        return true
    }

    // Пробуем сравнить с помощью метода Equal.
    if eq, ok := any(a).(equaler[T]); ok {
        return eq.Equal(b)
    }

    // Иначе сравниваем через рефлексию.
    return reflect.DeepEqual(a, b)
}

Во-вторых, мы можем быстро сравнивать байтовые срезы через bytes.Equal:

// areEqual проверяет, что a равно b.
func areEqual[T any](a, b T) bool {
    // ...

    // Особый случай для байтовых срезов.
    if aBytes, ok := any(a).([]byte); ok {
        bBytes := any(b).([]byte)
        return bytes.Equal(aBytes, bBytes)
    }

    // ...
}

Наконец, будем вызывать функцию areEqual внутри AssertEqual:

// AssertEqual проверяет, что got равно want.
func AssertEqual[T any](tb testing.TB, got T, want T) {
    tb.Helper()
    if areEqual(got, want) {
        return
    }
    tb.Errorf("want %#v, got %#v", want, got)
}

Посмотрим, как работает:

func Test(t *testing.T) {
    // date1 и date2 обозначают одно и то же время.
    date1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
    date2 := time.Date(2025, 1, 1, 5, 0, 0, 0, time.FixedZone("UTC+5", 5*3600))
    AssertEqual(t, date1, date2) // ok

    // b1 и b2 - одинаковые байтовые срезы.
    b1 := []byte("abc")
    b2 := []byte{97, 98, 99}
    AssertEqual(t, b1, b2) // ok

    // m1 и m2 - отличающиеся карты.
    m1 := map[string]int{"age": 25}
    m2 := map[string]int{"age": 42}
    AssertEqual(t, m1, m2) // fail
}
FAIL: Test (0.00s)
main_test.go:47: want map[string]int{"age":42}, got map[string]int{"age":25}

Работает отлично!

Проверка ошибок

Ошибки в Go используются постоянно, поэтому их проверка — важная часть тестирования:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    _, err := db.Str().Get("name") // игнорируем значение для краткости
    if err != nil {
        t.Errorf("unexpected error: %v'", err)
    }
}
PASS

Проверка ошибок, по моей оценке, составляет до 30% всех ассертов, поэтому вынесем ее в отдельную функцию.

Сначала реализуем базовые сценарии — когда ошибки не должно быть, и когда ожидается ошибка:

// AssertErr проверяет, что ошибка got соответствует want.
func AssertErr(tb testing.TB, got error, want error) {
    tb.Helper()

    // Ожидали nil ошибку, но получили не-nil.
    // Это критическая проблема, поэтому сразу завершаем тест.
    if want == nil && got != nil {
        tb.Fatalf("unexpected error: %v", got)
        return
    }

    // Ожидали не-nil ошибку, а получили nil.
    if want != nil && got == nil {
        tb.Errorf("want error, got <nil>")
        return
    }

    // Остальное сделаем чуть позже.
    return
}

Обычно мы не останавливаем тест, если отдельный ассерт упал. Это позволяет увидеть все проблемы разом, а не выискивать их по одной. Единственное исключение — ситуация unexpected error в коде выше (ожидали nil, получили не-nil). Здесь тест сразу завершается, потому что следующие проверки, скорее всего, уже не имеют смысла и могут вызвать панику.

Посмотрим, как работает:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    _, err := db.Str().Get("name")
    AssertErr(t, err, nil)
}
PASS

Пока неплохо. Теперь реализуем остальные проверки, но без создания отдельных функций (ErrorIs, ErrorAs, ErrorContains и т.д.), как это делает testify.

Если want — ошибка (тип error), используем errors.Is, чтобы проверить, совпадает ли ошибка с ожидаемым значением:

// AssertErr проверяет, что ошибка got соответствует want.
func AssertErr(tb testing.TB, got error, want any) {
    tb.Helper()

    if want != nil && got == nil {
        tb.Error("want error, got <nil>")
        return
    }

    switch w := want.(type) {
    case nil:
        if got != nil {
            tb.Fatalf("unexpected error: %v", got)
        }
    case error:
        if !errors.Is(got, w) {
            tb.Errorf("want %T(%v), got %T(%v)", w, w, got, got)
        }
    default:
        tb.Errorf("unsupported want type: %T", want)
    }
}

Пример:

func Test(t *testing.T) {
    err := &fs.PathError{
        Op: "open",
        Path: "file.txt",
        Err: fs.ErrNotExist,
    }
    AssertErr(t, err, fs.ErrNotExist)
}
PASS

Если want — строка, проверим, что сообщение об ошибке содержит ожидаемый текст:

// AssertErr проверяет, что ошибка got соответствует want.
func AssertErr(tb testing.TB, got error, want any) {
    // ...
    switch w := want.(type) {
    case string:
        if !strings.Contains(got.Error(), w) {
            tb.Errorf("want %q, got %q", w, got.Error())
        }
    //...
    }
}

Пример:

func Test(t *testing.T) {
    _, err := regexp.Compile("he(?o") // invalid
    AssertErr(t, err, "invalid or unsupported")
}
PASS

Наконец, если want — тип, используем errors.As, чтобы проверить, совпадает ли тип ошибки с ожидаемым:

// AssertErr проверяет, что ошибка got соответствует want.
func AssertErr(tb testing.TB, got error, want any) {
    // ...
    switch w := want.(type) {
    case reflect.Type:
        target := reflect.New(w).Interface()
        if !errors.As(got, target) {
            tb.Errorf("want %s, got %T", w, got)
        }
    //...
    }
}

Пример:

func Test(t *testing.T) {
    got := &fs.PathError{
        Op: "open",
        Path: "file.txt",
        Err: fs.ErrNotExist,
    }

    var want *fs.PathError
    AssertErr(t, got, reflect.TypeOf(want))

    // Или так.
    AssertErr(t, got, reflect.TypeFor[*fs.PathError]())
}
PASS

И вот что еще. Хотелось бы иметь возможность проверить, что произошла просто какая-то ошибка, не проверяя ее значение или тип (так работает Error в testify). Чтобы поддержать такой сценарий, сделаем параметр want необязательным:

// AssertErr проверяет, что ошибка got соответствует любому из wants.
func AssertErr(tb testing.TB, got error, wants ...any) {
    tb.Helper()

    // Если wants не указаны, ожидаем, что got будет не-nil ошибкой.
    if len(wants) == 0 {
        if got == nil {
            tb.Error("want error, got <nil>")
        }
        return
    }

    // Здесь для простоты сравниваем got только с первым want.
    // Можно (и стоило бы) сравнивать с каждым из wants.
    want := wants[0]
    // ...
}

Пример:

func Test(t *testing.T) {
    _, err := regexp.Compile("he(?o") // invalid
    AssertErr(t, err) // хотим не-nil ошибку
}
PASS

Теперь AssertErr обрабатывает все нужные нам случаи:

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

И все это в 40 строк кода. Неплохо, правда?

Другие проверки

AssertEqual и AssertErr покрывают 85-95% тестовых проверок в типичном проекте. Но все равно остаются эти злосчастные 5-15%.

Например, нам могут понадобиться такие проверки:

func Test(t *testing.T) {
    s := "go is awesome"
    if len(s) < 5 {
        t.Error("too short")
    }

    if !strings.Contains(s, "go") {
        t.Error("too weak")
    }
}
PASS

Технически, можно использовать AssertEqual. Но выглядит это так себе:

func Test(t *testing.T) {
    s := "go is awesome"
    AssertEqual(t, len(s) >= 5, true)
    AssertEqual(t, strings.Contains(s, "go"), true)
}
PASS

Поэтому давайте добавим третий и последний ассерт — AssertTrue. Эта функция самая простая:

// AssertTrue проверяет, что got истинно.
func AssertTrue(tb testing.TB, got bool) {
    tb.Helper()
    if !got {
        tb.Error("not true")
    }
}

Теперь логические проверки выглядят лучше:

func Test(t *testing.T) {
    s := "go is awesome"
    AssertTrue(t, len(s) >= 5)
    AssertTrue(t, strings.Contains(s, "go"))
}
PASS

Неплохо!

Заключение

Не думаю, что для тестов в гошном проекте нужны сорок разных видов ассертов. Вполне достаточно трех (а то и двух), если они корректно проверяют на равенство и обрабатывают разные ситуации с ошибками.

Я постоянно использую «трио ассертов» — Equal, Err и True — в своих проектах. Поэтому вынес их в отдельный мини-пакет github.com/nalgeon/be. Если вам понравился описанный в статье подход — попробуйте и вы!

★ Подписывайтесь на новые заметки.