👉Введение
Написание модульных тестов в проектах, требующих длительных итераций, постепенно стало лицемерным консенсусом среди различных команд. Хотя я говорю хорошие вещи, мое тело очень честное.
Написание модульных тестов в проектах, требующих длительных итераций, постепенно стало лицемерным консенсусом среди различных команд. Хотя я говорю хорошие вещи, мое тело очень честное. Ведь написание модуля требует дополнительных сил и времени помимо реализации бизнес-функций, поэтому многие считают это большой нагрузкой. Есть две основные фундаментальные проблемы, которые вызывают такое восприятие. -потребляющий. При тестировании часто уходит много времени на разработку вариантов использования теста, а для того, чтобы запустить тестируемую функцию, нужно много времени на создание для нее рабочей среды, инициализацию переменной, макетирование Объекты и т. д. Иногда я даже чешу голову и не умею писать тесты. Поэтому данная статья основана на Go На примере языка мы поговорим о том, как проектировать и писать бизнес-код, который легко тестировать.
Фактически, если мы сознательно проектируем структуры данных и интерфейсы функций, наш код можно легко протестировать без каких-либо особых навыков. Однако на практике большинство студентов не имеют представления о «Для тестирования» на этапе проектирования и, естественно, пишут код, который трудно протестировать. Важно понимать, что легкость тестирования кода и наличие четкой логической структуры — это две разные вещи. Четкая логика не означает, что код легко тестировать. Даже опытные программисты будут писать код, который сложно протестировать, если они этого не делают. осторожны, например:
func GetUserInfo(uid int64) (*UserInfo, error) {
key := buildUserCacheKey(uid)
val, err := redis.NewClient(USERDB).GetString(key)
if err == nil {
return unmarshalUserInfoFromStr(val)
}
res, err := mysqlPool.GetConn().Query("select * from user where uid=?", uid)
// ...
}
Логика приведенного выше кода по-прежнему очень понятна (не для хвастовства): сначала получите кеш от Redis, если не получите, перейдите за ним в MySQL. Хотя текст легко читается, если бы вас попросили написать модульный тест для этой функции, у вас были бы проблемы. Поскольку для получения данных функция должна обращаться к Redis, она вообще не может подключиться к Redis в среде разработки. Даже если он подключен, в Redis нет данных. То же самое касается MySQL. И заметили ли вы, что над этими зависимостями вообще нельзя издеваться? При написании одного теста для функции GetUserInfo у меня не было возможности контролировать поведение объектов MySQL и Redis. Если нет возможности их контролировать, то и писать тестовый код действительно невозможно.
Тогда давайте перейдем к делу: как написать бизнес-код, который легко протестировать.
Сколько шагов нужно, чтобы положить слона в холодильник?
Конечно, это всего лишь шутка. Открыть и закрыть дверь просто, но засунуть слона не так просто. Однако если вы сознательно учитываете возможность тестирования при написании бизнес-кода, то писать модульные тесты действительно легко. Есть два основных шага:
Эти два шага очень интуитивно понятны и просты для понимания, но почему на практике так сложно написать один тест?
Чтобы внести ясность в этот вопрос,Сначала я хочу поговорить очистая функцияконцепция。еслифункцияудовлетворить:
Тогда эта функция является чистой функцией. Существует множество примеров чистых функций, и почти все они в стандартной библиотеке Go являются чистыми функциями. Мы также можем сами реализовать некоторые чистые функции, например:
func Add(a, b int) int {
return a+b
}
func getRedisUserInfoKey(uid int64) {
return fmt.Sprintf("uinfo:%d", uid)
}
func sortByAgeAsc(userList []User) []User {
n := len(userList)
for i:=0; i<n; i++ {
for j := i+1; j<n; j++ {
if userList[i].Age > userList[j].Age {
userList[i], userList[j] = userList[j], userList[i]
}
}
}
return userList
}
func ParseInt(s string) (int64, error) {
// ...
}
чистая функция Самая большая особенностьдакак результаттолько принятьвходной контроль,Когда входные параметры подтверждены,Определяется выходной результат. Существует детерминированная связь между входными параметрами и выходными результатами (хотя она может быть сложной).,Точно так же, как математические функции. На основании этой характеристики,Для функции очистки очень легко написать сценарии использования теста.,Особенно табличный тест,например:
var testCases = []struct{
input string
expectOutput int64
expectErr error
}{
{"100",100,nil,},
{"-99999",-99999,nil,},
{"1.2",0,ErrNotInt,},
// ...
}
for _, tc := range testCases {
actual, err := ParseInt(tc.input)
assert_eq(tc.expectOutput, actual)
assert_eq(tc.expectErr, err)
}
Написание тестовых примеров на основе таблиц — лучший способ написания отдельных тестов, без исключений. Для каждого набора тестов сразу понятно, какие входные данные, какие выходные данные должны быть и какая ошибка должна быть возвращена в случае возникновения ошибки. И мы можем легко добавить больше тестовых примеров, не изменяя другие части кода.
Но в реальном развитии бизнеса мы редко пишем чистые функции, большинство из них являются нечистыми, например:
func NHoursLater(n int64) time.Time {
return time.Now().Add(time.Duration(n) * time.Hour)
}
Эта функция возвращает n часов спустя. Хотя получение параметра n,нода Фактически, результат каждого выполнениядаслучайный,из-за этогофункция Помимо зависимостей n Также зависит от текущего времени. Значение текущего времени не контролируется вызывающей стороной и постоянно меняется, поэтому вы не можете предсказать, когда будет введено значение. n Что будет выведено после Function. Это самом деле Сразуда Очень типичныйнеявная зависимость——Хотя мы ввели параметры A, но функция также неявно зависит от других параметров.
Давайте посмотрим на другой пример:
func GetUserInfoByID(uid int64) (*UserInfo, error) {
val, err := mysqlPool.GetConn().Query("select * from t_user where id=? limit 1", uid)
if err != nil {
return nil, err
}
return UnmarshalUserInfo(val)
}
Проблема с этой функцией аналогична: даже если вы передадите uid, вы не сможете определить, какое значение вернет функция, поскольку она полностью зависит от возврата внутреннего модуля MySQL. Это очень распространенные примеры в повседневном бизнес-коде. Вы можете подумать об этом: если бы вас попросили написать один тест для двух вышеупомянутых нечистых функций, что бы вы сделали?
Фактически, если функция реализована так, как в двух приведенных выше примерах, протестировать ее практически невозможно, кроме как с помощью MonkeyPatch. Но поскольку это показательная операция, я не буду здесь вдаваться в подробности. К MonkeyPatch следует относиться в крайнем случае. Если нам приходится использовать MonkeyPatch при написании теста для определенной функции, это означает лишь то, что с кодом что-то не так. MonkeyPatch должен появляться только при составлении одиночных тестов для старых проектов. Подробнее я расскажу о том, как писать тестируемый код.
на самом деле поговорим о приведенном выше примере,величайшая цель Сразуда Я хочу тебе кое-что сказать:Если вы хотите легко протестировать функцию, вам нужно найти способ сделать все переменные, от которых зависит функция, управляемыми.чтобы сделать это,Я суммировал некоторые руководящие мысли:
Самый простой способ — передать все зависимости функции в качестве входных параметров. В приведенном выше примере мы можем изменить его следующим образом:
func NHoursLater(n int64, now time.Time) time.Time {
return now.Add(time.Duration(n) * time.Hour)
}
func GetUserInfoByID(uid int64, db *sql.DB) (*UserInfo, error) {
val, err := db.Query("select * from t_user where id=? limit 1", uid)
if err != nil {
return nil, err
}
return UnmarshalUserInfo(val)
}
После этого преобразования, хотя при вызове необходимо создать экземпляры некоторых дополнительных объектов, это не является большой проблемой, и нашу функцию легче тестировать. Для функции NHoursLater я могу установить значение now по своему усмотрению и посмотреть, соответствуют ли результаты ожиданиям. Это очень легко проверить. Но во втором примере есть некоторые проблемы, поскольку передаваемый параметр — это *sql.DB, указатель на объект структуры, и мне сложнее контролировать его поведение. Поскольку sql.DB — это объект, реализованный стандартной библиотекой, все его методы реализованы в стандартной библиотеке и не могут быть изменены. Поэтому вам следует рассмотреть возможность использования здесь интерфейса Go, например:
type Queryer interface {
Query(string, args ...interface{}) (*sql.Rows, error)
}
func GetUserInfoByID(uid int64, db Queryer) (*UserInfo, error) {
val, err := db.Query("select * from t_user where id=? limit 1", uid)
if err != nil {
return nil, err
}
return UnmarshalUserInfo(val)
}
Вы можете сразу увидеть преимущества использования интерфейса здесь! Интерфейс ограничивает поведение объектов, но не ограничивает реализацию конкретных объектов, так называемая динамическая диспетчеризация. Поэтому, когда мы пишем тестовый код, мы можем просто реализовать Queryer, чтобы контролировать его поведение при завершении теста, например:
type mockQuery struct {}
func (m *mockQuery) Query(string, args ...interface{}) (*sql.Rows, error) {
return sqlmock.NewRows([]string{"id", "name", "age"}).AddRow(1, "jerry", 5).AddRow(2, "tom", 7)
}
func TestGetUserInfoByID(t *testing.T) {
userInfo, err := GetUserInfoByID(1, new(mockQuery ))
assert_eq(err, nil)
assert_eq(*userInfo, UserInfo{ID: 1, Name:"jerry", Age: 5})
}
Затем вы можете написать дополнительные тестовые примеры для этой функции в табличном виде с помощью собственного фиктивного объекта.
Подводя краткий итог, можно подвести итогТрилогия отстранения:
Извлечение зависимостей во входные параметры — распространенный метод, но он не полностью применим в некоторых сценариях, поскольку некоторые функции имеют слишком много зависимостей, например:
func NewOrder(user UserInfo, order OrderInfo) error {
// Идемпотентное обнаружение
if err := idempotenceCheck(user, order); err != nil {
return err
}
// Перейдите в систему заказов, чтобы создать заказ и вернуть информацию об успешно созданном заказе.
newInfo, err := orderSystem.NewOrder(user, order)
if err != nil {
return err
}
// Отправить информацию о заказе в new_order очереди сообщений. в теме
err = mq.SendToTopic("new_order", newInfo)
if err != nil {
return err
}
// Сохраняйте информацию о заказе в Redis, чтобы облегчить запрос пользователя.
cacheKey := getUserOrderCacheKey(user.ID)
redis.Hset(cacheKey, newInfo.ID, newInfo)
return nil
}
Вышеуказанное представляет собой упрощенную функцию создания заказов. Помимо использования userInfo и orderInfo, она также использует нисходящую систему для обнаружения идемпотентности. Для создания заказов она использует систему заказов. Она должна помещать сообщения в очередь сообщений, кеш. данные в Redis и т. д. Если просто преобразовать зависимости в параметры функции, например:
func NewOrder(user UserInfo, order OrderInfo, idempotent IdemChecker, orderSystem OrderSystemSDK, mq KafKaPusher, redis Redis.Client) error {
// ...
}
Приведенная выше сигнатура функции будет очень сложной, и перед вызовом функции вызывающему объекту необходимо создать экземпляры многих объектов. Хоть тестирование и удобно, но использовать его в деле крайне неудобно. И что еще более серьезно, если позже в код будут добавлены некоторые функции защиты от мошенничества и фильтрации безопасности пользователей, и все эти функции зависят от нижестоящих микросервисов, нужно ли им все равно каждый раз менять сигнатуру функции? Очевидно, что это неприемлемо. Поэтому нам следует рассмотреть второй подход.
Если мы реализуем функцию,Затем зависимости, которые может использовать функция, передаются либо через параметры, либо через параметры.,Или просто процитируйте глобальную переменную. Если слишком много зависимостей,Передавать да через параметры нереально,Это кажется Сразу Только глобальныйпеременная Это сделано??не забывайМетоды объекта:
type Foo struct {
Name string
Age int
}
func (f *Foo) Bar(a,b,c int) string {
// f.Name
// f.Age
}
В методе объекта, хотя имеется только три входных параметра a, b и c, на самом объекте (this или self в других языках) фактически можно ссылаться. Сам объект может иметь бесконечное количество переменных-членов, поэтому, реализуя методы объекта вместо функций, мы можем легче добавлять зависимости, например:
type orderCreator struct {
checker IdemChecker
orderSystem OrderSystemSDK
kafka KafkaPusher
redis Redis.Client
}
func (self *orderCreator) NewOrder(user UserInfo, order OrderInfo) error {
// ...
}
Помещая зависимости внутри объекта, мы можем легко управлять нашими зависимостями. При написании тестового кода мы можем просто написать конструктор по мере необходимости:
func constructOrderCreator() *orderCreator {
return &orderCreator{
checker: newMockChecker(),
// ...
}
}
func TestNewOrder(t *testing.T) {
obj := constructOrderCreator()
obj.NewOrder(user, order)
}
Этот метод на самом деле является своего рода абстракцией зависимостей. Он просто абстрагирует зависимости в объект, а не помещает их в параметры. Он может поддерживать сложные зависимости. Независимо от того, сколько существует зависимостей, просто добавьте элементы в определение структуры. Недостаток заключается в том, что создание экземпляра немного громоздко, поэтому каждый обработчик запроса редко создается один раз. Обычно он использует глобальный объект, поэтому он создается только один раз (во избежание его недостатков) или с помощью шаблона фабрики. А при написании тестов, поскольку Go не является RAII-языком, мы можем лениться и выполнять лишь частичную реализацию. Другими словами, если я знаю, что obj.FuncA использует только obj.X, то при создании экземпляра obj я создаю только экземпляр obj.X.
В дополнение к двум вышеперечисленным методам,Есть еще один очень распространенный способ,СразудаПеременная функции。
Давайте сначала посмотрим на пример:
import (
"repo/group/proj/log"
)
func add(ctx context.Context, a,b int) int {
c := a+b
log.InfoContextf(ctx, "a+b=%d", c)
return c
}
Журналирование можно увидеть повсюду в бизнес-коде. Если тестируемая функция содержит операторы журналирования, вы часто сталкиваетесь со следующими проблемами:
Как и в приведенном выше примере, log.InfoContextf да log Статический метод, предоставляемый пакетом, log даа сумка, а не дааобъект,Поэтому я не могу поместить его как дочерний объект в объект. Для этого сценария,нас СразурассмотретьПеременная функцииПонятно。так называемый Переменная функциина самом деле Сразудаиспользоватьодинпеременнаясохранитьфункцияуказатель,например:
import (
"domain/group/proj/log"
)
var (
infoContextf = log.InfoContextf
)
func add(ctx context.Context, a,b int) int {
c := a+b
infoContextf(ctx, "a+b=%d", c)
return c
}
Мы используем infoContextf сохранить log.InfoContextf Выполнение указателя функции кажется еще одной адресацией памяти, но на самом дело вообще не имеет значения. Но польза от этого огромна, потому что когда мы пишем вариант использования, мы можем сделать следующее:
type logHandler func(context.Context, string, ...interface{})
// Замените указатель функции своей собственной реализацией.
func replaceinfoContextf(f logHandler) func() {
old := infoContextf
infoContextf = f
return func() {
infoContextf = old
}
}
// Реализуйте логфункцию самостоятельно и ничего не делайте
func logDiscard(_ context.Context, _ string, _...interface{}) {
return
}
func TestAdd(t *testing.T) {
// Замените infoContextf на logDiscard перед тестом.
resume := replaceinfoContextf(logDiscard)
// Автоматически возобновлять работу после завершения теста
defer resume()
// do your testing
}
Нам больше не нужно беспокоиться о том, что журнал не будет инициализирован, мы можем сделать это сами. mock Бревно функция ручки! Кроме бревно, на самом деле существует много таких вызовов статических методов.,Мы все могли бы использовать переменнуюсохранить эту функцию.,например:
// in bussiness file
var (
hostName = os.HostName
getNow = time.Now
openFile = os.Open
// ...
)
func NHoursLater(n int64) time.Time {
return getNow().Add(time.Duration(n)*time.Hour)
}
// in test file
func TestNHoursLater(t *testing.T) {
now := time.Now()
fiveHoursLater := now.Add(time.Duration(5)*time.Hour)
getNow = func() time.Time {
return now
}
assert_eq(NHoursLater(5), fiveHoursLater)
}
Избегайте вызова статических методов непосредственно внутри функции. С помощью этих «переменных указателей функций» мы можем легко заменить их нашей собственной реализацией во время тестирования, экранируя системные различия, разницу во времени и другие факторы вне программы, чтобы можно было протестировать тестовый код. каждый раз Все они могут работать в одной и той же среде.
Переменная функциина самом деле Сразуда Что мы часто говоримштабелирование。
на самом деле Некоторые из упомянутых выше методов кодирования не включают в себя какие-либо сложные шаблоны проектирования.,Никакой технической глубины здесь нет. Это всего лишь некоторые процедуры программирования,Но прежде чем писать существующий бизнес-код, вы должны уметь писать отдельные тесты.,Только тогда вы сможете написать бизнес-код, который будет прост в использовании.
Подводя итог, можно выделить две простые руководящие идеи:
Конкретные методы добычи:
Учитывая эти моменты, очень легко писать бизнес-код, который легко тестировать.
В то же время мы можем заняться созданием набора тестов, поскольку большая часть этого требует mock все объекты являются общими внешними зависимостями, особенно MySQL Redis и т. д., поэтому мы можем реализовать некоторые общие testsuite, удобный для нас в настройке mock поведение объекта без необходимости каждый раз писать много кода для его реализации mock объект. например:
Чем богаче будут эти наборы тестов, тем легче нам будет писать тесты (ну же, команда Wheel).
на самом деле Go Есть некоторые дополнительные факторы, которые повлияют на наше написание одиночных тестов, это одна из его особенностей — init. инициализировать существовать Go средний класс самом деледа — очень спорная функция. Многие люди против ее использования. Некоторые даже спрашивали. Go2 нести proposal Хочу удалить init (конечно это нереально). Основная причина - да, если пакет содержит init функция,это будетсуществовать main Выполнять перед началом выполнения (также запускать до запуска нашей единственной тестовой функции).
Это вызывает проблему,Потому что внедрение этих пакетов имеет побочные эффекты,Например, они прочитают файл конфигурации в оговоренном месте.,Зарегистрируйте какой-нибудь глобальный объект,или Человек, пытающийся подключиться к услуге, обнаружил agent зарегистрировать услугу. Если есть проблема в какой-либо ссылке, то уровень платформы посчитает, что инициализация не удалась, и, скорее всего, будет напрямую паника. Но Датисна самом дело повлияет на работу нашего единственного теста. Время выполнения одного теста не зависит от реальной среды, а потому, что init характеристики, если это действительно init функция приводит к Паника, мы, вероятно, не сможем провести ни одного теста.
Еще вопрос да, init последовательность выполнения на самом деледаи import Порядок связан, и в нем есть вложенная логика. и gofmt Может быть скорректировано import порядке, иногда это может быть связано с init Порядок исполнения непоследователен и некоторые ошибки, и их трудно устранить. Если фреймворк был тщательно протестирован, используйте init В общем, не используйте его при написании бизнес-кода самостоятельно. init,я бы предпочел написать это сам InitXXX Затемсуществовать main Вызывается вручную в функции.