Выразительные тесты без 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. Если вам понравился описанный в статье подход — попробуйте и вы!
★ Подписывайтесь на новые заметки.