MBL
Go / avito-start-code-quality / Задание 1: табличный тест для параллельной SumSquares
Go вводный

Задание 1: табличный тест для параллельной SumSquares

testingtable-drivenerrorsconcurrency

Первое задание практики про качество кода — не новая бизнес-логика, а табличный тест на уже готовую параллельную функцию SumSquares. Тест был расставлен как есть с двумя TODO: пустой слайс кейсов и незаполненная проверка результата.

Что делает SumSquares

func SumSquares(numbers []int) (int, error) {
    results := make(chan int)
    errorsCh := make(chan error, 1)

    var workers sync.WaitGroup
    for _, number := range numbers {
        workers.Add(1)
        go func() {
            defer workers.Done()
            if number < 0 {
                select {
                case errorsCh <- fmt.Errorf("%w: %d", ErrNegativeNumber, number):
                default:
                }
                return
            }
            results <- number * number
        }()
    }

    go func() {
        workers.Wait()
        close(results)
    }()

    total := 0
    for result := range results {
        total += result
    }

    select {
    case err := <-errorsCh:
        return 0, err
    default:
        return total, nil
    }
}

На каждое число запускается отдельная горутина: неотрицательные считают квадрат и отправляют его в results, отрицательные пишут обёрнутую ошибку в errorsCh и завершаются без записи в results. Отдельная горутина ждёт workers.Wait() и закрывает results — это стандартный приём, чтобы for result := range results в основном потоке корректно завершился, когда все воркеры отработали, а не висел вечно.

Форма табличного теста

Табличный тест — слайс анонимных структур, где каждый элемент описывает один сценарий: вход, ожидаемый результат, ожидаемая ошибка. Цикл прогоняет их все через t.Run, что даёт отдельную строку в выводе go test -v на каждый кейс и позволяет сфокусированно перезапустить один из них через -run.

tests := []struct {
    name    string
    numbers []int
    want    int
    wantErr error
}{
    {
        name:    "обычный набор чисел",
        numbers: []int{1, 2, 3},
        want:    14,
        wantErr: nil,
    },
    {
        name:    "пустой слайс",
        numbers: []int{},
        want:    0,
        wantErr: nil,
    },
    {
        name:    "отрицательное число",
        numbers: []int{1, -2, 3},
        want:    0,
        wantErr: ErrNegativeNumber,
    },
}

1² + 2² + 3² = 1 + 4 + 9 = 14 — обычный случай без ошибок. Пустой слайс — краевой случай: цикл по numbers не запускает ни одной горутины, workers.Wait() возвращается мгновенно, results закрывается пустым, total остаётся 0. Третий случай — то же самое подмножество чисел, но с одним отрицательным: ожидаемый результат не важен (функция гарантированно вернёт 0 при ошибке), важна сама ошибка.

errors.Is, а не прямое сравнение

Функция оборачивает ошибку через %w, а не возвращает ErrNegativeNumber напрямую:

fmt.Errorf("%w: %d", ErrNegativeNumber, number)

Из-за этого сравнение err == ErrNegativeNumber всегда будет false — err это новое значение типа *fmt.wrapError, а не тот же указатель, что ErrNegativeNumber. errors.Is разворачивает цепочку через метод Unwrap(), который автоматически генерируется для %w, и находит ErrNegativeNumber внутри — поэтому именно он, а не ==, должен стоять в проверке теста:

if tt.wantErr != nil {
    if !errors.Is(err, tt.wantErr) {
        t.Fatalf("SumSquares() error = %v, want wrapping %v", err, tt.wantErr)
    }
    return
}
if err != nil {
    t.Fatalf("SumSquares() unexpected error = %v", err)
}
if got != tt.want {
    t.Errorf("SumSquares() = %d, want %d", got, tt.want)
}

t.Fatalf на неожиданной ошибке останавливает подтест сразу — дальше сравнивать got бессмысленно, значение всё равно 0. t.Errorf в последней проверке — обычная практика: несовпадение результата не мешает тесту продолжить (здесь дальше и так нечего проверять, но это привычка, которая окупается в более длинных тестах, где после несовпадения одного поля стоит проверить остальные).

Одна ошибка на несколько отрицательных чисел — не баг, а особенность буфера

errorsCh := make(chan error, 1) — буфер ровно на одно значение. Если в списке несколько отрицательных чисел, каждая соответствующая горутина пытается отправить свою ошибку через select с default: первая, кто успеет, займёт единственное место в буфере, все остальные попадут в ветку default и молча отбросят свою ошибку.

Это не влияет на тест из задания — там ровно одно отрицательное число, поведение детерминированное. Но стоит держать в голове при чтении кода: SumSquares([]int{-1, -2, -3}) гарантированно вернёт ошибку про какое-то отрицательное число, но не гарантирует, про какое именно — это состояние гонки между тремя горутинами за один слот буфера. Для функции, где важно поймать именно первую или именно все ошибки, такая конструкция не подошла бы; здесь она осознанно упрощена, потому что вызывающему коду достаточно знать сам факт «был отрицательный вход».

Запустить и проверить

go test -v ./01-table-test

Ожидаемый вывод — три PASS подтеста под общим TestSumSquares, каждый по имени сценария (кириллица в имени t.Run автоматически подменяется движком go test на подчёркивания в выводе — это нормально, не ошибка).

Живой пример работы функции — тот же код SumSquares, что в задании, прогнанный на успешном и ошибочном входе:

на play.golang.org
Самопроверка 0 / 3
Могу описать форму табличного теста: слайс структур с полями input/want, цикл с t.Run по каждому кейсу
Понимаю, почему для обёрнутой ошибки нужен errors.Is, а не сравнение err == ErrNegativeNumber
Заметил, что errorsCh буферизован на 1 элемент — при нескольких отрицательных числах вернётся только одна из ошибок
Как усвоено?