Приступайте к модульному тестированию (1) — основные принципы

Go

Модульное тестирование — гарантия качества кода. Эта серия статей шаг за шагом покажет, как выполнять модульное тестирование в Go.

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

Теперь давайте начнем с основных идей и принципов модульного тестирования и посмотрим, как выполнять модульное тестирование на основе стандартного тестового пакета, предоставляемого Go.

Сложности модульного тестирования

1. Освойте детализацию модульного тестирования

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

2. Устранение внешних зависимостей (технология mock, stub)

Модульные тесты вообще не допускают каких-либо внешних зависимостей (зависимостей файлов, сетевых зависимостей, зависимостей базы данных и т. д.), и мы не будем подключаться к базе данных, вызывать API и т. д. в тестовом коде. Эти внешние зависимости необходимо имитировать (mock/stub) при выполнении тестов. Во время тестирования мы используем фиктивные объекты для имитации различных вариантов поведения при реальных зависимостях. Использование mock/stub для имитации реального поведения системы является камнем преткновения на пути модульного тестирования. Не волнуйтесь, эта статья покажет вам, как использовать макеты/заглушки для модульного тестирования в Go с примерами.

Иногда моделирование эффективно и удобно. Но мы должны остерегаться чрезмерного количества макетов/заглушек, так как они могут привести к модульным тестам, в основном тестирующим фиктивные объекты, а не реальную систему.

Costs and Benefits

Используя преимущества модульного тестирования, оно также неизбежно увеличивает объем кода и затраты на обслуживание (код модульного тестирования также необходимо поддерживать). этот нижеДиаграмма квадранта стоимость/ценностьНаглядно иллюстрирует модульное тестирование в системах различной природы.СтоимостьиценностьОтношения между.

1. Простой код с небольшим количеством зависимостей (внизу слева)

Для меньшего количества внешних зависимостей код прост. Естественно, его стоимость и стоимость относительно невелики. Возьмите в качестве примера пакет ошибок в официальной библиотеке Go, весь пакет имеет два метода.New()иError(), без каких-либо внешних зависимостей, код также очень прост, поэтому его модульное тестирование также достаточно удобно.

2. Код со многими зависимостями, но очень простой (внизу справа)

Чем больше зависимостей, тем неизбежно будет увеличиваться число макетов и заглушек, а также возрастет стоимость модульного тестирования. Но код настолько прост (как в примере с пакетом ошибок выше), стоимость написания модульных тестов в настоящее время больше, чем его ценность,Юнит-тесты лучше не писать.

3. Сложный код с небольшим количеством зависимостей (вверху слева)

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

4. Многое зависит и очень сложно (вверху справа)

Такой код, очевидно, является кошмаром для модульного тестирования. Пишите модульные тесты, это дорого, не пишите модульные тесты, это слишком рискованно. Подобно этому коду, мы пытаемся разделить его на две части: 1. Часть обработки сложной логики 2. Часть обработки зависимостей, а затем 1 часть для модульного тестирования.

Оригинальная ссылка:blog.Stevens Anderson.com/2009/11/04/…

Сделайте первый шаг в модульном тестировании

1. Определите зависимости и абстрагируйте их в интерфейсы

Определите внешние зависимости в системе.Вообще говоря, наиболее распространенные зависимости, с которыми мы сталкиваемся, это не что иное, как следующее:

  1. Сетевые зависимости — выполнение функции зависит от сетевых запросов, таких как сторонние http-api, службы rpc, очереди сообщений и т. д.
  2. зависимость от базы данных
  3. Зависимость ввода-вывода (файл)

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

2. Уточните, что нужно измерять

Когда мы начинаем набирать код продукта, мы должны уже сделать предварительный дизайн и понять внешние зависимости в системе и сложные части бизнеса, и эти части должны отдавать приоритет написанию модульных тестов. При написании каждого метода/структуры нужно ли вам одновременно думать об этом методе/структуре? Как проверить? Для каких методов/конструкций нужно тестировать, а какие нельзя делать, кроме вышеперечисленныхДиаграмма квадранта стоимость/ценностьВ дополнение к получению ответа в , вы также можете обратиться к следующему оНасколько хороша гранулярность модульного теста?Ответы на вопросы:

Мой босс платит за мой код, а не за тестирование, поэтому мои ценности для этого таковы — чем меньше тестирования, тем лучше, пока вы не добьетесь какой-то уверенности в качестве своего кода(Я думаю, что такой стандарт уверенности в себе должен быть выше, чем отраслевой стандарт, конечно, такая уверенность в себе может быть своего рода высокомерием). Если бы я не сделал эту типичную ошибку в своей карьере программиста (например, установил неправильное значение в конструкторе), я бы не стал его тестировать. Я склонен проверять на наличие значимых ошибок,Итак, я очень осторожен с более сложной условной логикой. Когда в команде,Я буду очень осторожен при тестировании кода, который делает команду подверженной ошибкам..классная оболочка.cai/articles/82…

Как имитировать и заглушать

Mock (симуляция) и Stub (заглушка) — два распространенных технических средства для имитации поведения внешних зависимостей в процессе тестирования. С помощью Mock and Stub мы можем не только сделать тестовую среду свободной от внешних зависимостей, но и смоделировать некоторые ненормальные поведения, такие как недоступность службы базы данных, отсутствие прав доступа к файлам и так далее.

Разница между Mock и Stub

На языке Go Mock и Stub можно описать так:

  • Макет: создайте в тестовом пакете структуру, удовлетворяющую интерфейсу внешней зависимости.interface{}
  • Заглушка: создайте фиктивный метод в тестовом пакете, который заменит метод в сгенерированном коде.

Все еще немного абстрактно, вот пример.

Ложный пример

Макет: создайте в тестовом пакете структуру, удовлетворяющую интерфейсу внешней зависимости.interface{}

Производственный код:

//auth.go
//假设我们有一个依赖http请求的鉴权接口
type AuthService interface{    
    Login(username string,password string) (token string,e error)   
    Logout(token string) error
}

фиктивный код:

//auth_test.go
type authService struct {}
func (auth *authService) Login (username string,password string) (string,error){
    return "token", nil
}
func (auth *authService) Logout(token string) error{    
    return nil
}

Здесь мы используемauthServiceДостигнутоAuthServiceинтерфейс, тест такойLogin,LogoutБольше не нужно полагаться на сетевые запросы. И мы также можем смоделировать некоторые условия ошибки для тестирования:

//auth_test.go
//模拟登录失败
type authLoginErr struct {
	auth AuthService  //可以使用组合的特性,Logout方法我们不关心,只用“覆盖”Login方法即可
}
func (auth *authLoginErr) Login (username string,password string) (string,error) {
	return "", errors.New("用户名密码错误")
}

//模拟api服务器宕机
type authUnavailableErr struct {
}
func (auth *authUnavailableErr) Login (username string,password string) (string,error) {
	return "", errors.New("api服务不可用")
}
func (auth *authUnavailableErr) Logout(token string) error{
	return errors.New("api服务不可用")
}

Пример заглушки

Заглушка: создает фиктивный метод в тестовом пакете, который заменяет метод в сгенерированном коде. Вот пример из Библии языка Go (11.2.3): Производственный код:

//storage.go
//发送邮件
var notifyUser = func(username, msg string) { //<--将发送邮件的方法变成一个全局变量
    auth := smtp.PlainAuth("", sender, password, hostname)
    err := smtp.SendMail(hostname+":587", auth, sender,
        []string{username}, []byte(msg))
    if err != nil {
        log.Printf("smtp.SendEmail(%s) failed: %s", username, err)
    }
}
//检查quota,quota不足将发邮件
func CheckQuota(username string) {
    used := bytesInUse(username)
    const quota = 1000000000 // 1GB
    percent := 100 * used / quota
    if percent < 90 {
        return // OK
    }
    msg := fmt.Sprintf(template, used, percent)
    notifyUser(username, msg) //<---发邮件
}

Очевидно, что в процессе запуска модульных тестов мы определенно не отправляем электронное письмо пользователю. В книге метод заглушки используется для проверки:

//storage_test.go
func TestCheckQuotaNotifiesUser(t *testing.T) {
    var notifiedUser, notifiedMsg string
    notifyUser = func(user, msg string) {  //<-看这里就够了,在测试中,覆盖了发送邮件的全局变量
        notifiedUser, notifiedMsg = user, msg
    }

    // ...simulate a 980MB-used condition...

    const user = "joe@example.org"
    CheckQuota(user)
    if notifiedUser == "" && notifiedMsg == "" {
        t.Fatalf("notifyUser not called")
    }
    if notifiedUser != user {
        t.Errorf("wrong user (%s) notified, want %s",
            notifiedUser, user)
    }
    const wantSubstring = "98% of your quota"
    if !strings.Contains(notifiedMsg, wantSubstring) {
        t.Errorf("unexpected notification message <<%s>>, "+
            "want substring %q", notifiedMsg, wantSubstring)
    }
}

Как видите, в Go, если бы использовалась заглушка, это было бынавязчивыйДа, производственный код должен быть разработан в форме, которую можно заменить методами-заглушками. Результат приведенного выше примера: для тестирования используется исключительно глобальная переменнаяnotifyUserдля сохранения методов с внешними зависимостями. Однако в языке Go, который не поддерживает использование глобальных переменных, это явно неуместно. Поэтому этот метод заглушки не рекомендуется.

Мок в сочетании с заглушкой

Поскольку Stub не пропагандируется, можно ли отказаться от Stub при тестировании Go? Изначально я так и думал, но пока не прочитал этот перевод стандартная раскладка пакетов Golangперевести, хоть этот перевод и про компоновку пакета, тестовые примеры внутри стоит изучить.

//生产代码 myapp.go
package myapp

type User struct {
    ID      int
    Name    string
    Address Address
}
//User的一些增删改查
type UserService interface {
    User(id int) (*User, error)
    Users() ([]*User, error)
    CreateUser(u *User) error
    DeleteUser(id int) error
}

Обычный фиктивный метод:

//测试代码 myapp_test.go
type userService struct{
}
func (u* userService) User(id int) (*User,error) {
	return &User{Id:1,Name:"name",Address:"address"},nil
}
//..省略其他实现方法

//模拟user不存在
type userNotFound struct {
	u UserService
}
func (u* userNotFound) User(id int) (*User,error) {
	return nil,errors.New("not found")
}

//其他...

Вообще говоря, внутри мок-структуры мало переменных, и для каждого сценария, который нужно смоделировать (например, пользователь выше не существует), наиболее политкорректным методом должно быть создание новой мок-структуры. Это имеет два преимущества:

  1. Макетная структура очень проста, никаких дополнительных настроек не требуется, и ошибиться не так-то просто.
  2. Мокированная структура несет единственную ответственность, а тестовый код более понятен и читабелен.

Но в только что упомянутой статье он делает это:

//测试代码
// UserService 代表一个myapp.UserService.的 mock实现 
type UserService struct {
    UserFn      func(id int) (*myapp.User, error)
    UserInvoked bool

    UsersFn     func() ([]*myapp.User, error)
    UsersInvoked bool
    // 其他接口方法补全..
}

// User调用mock实现, 并标记这个方法为已调用
func (s *UserService) User(id int) (*myapp.User, error) {
    s.UserInvoked = true
    return s.UserFn(id)
}

Здесь реализован не только интерфейс, но и метод с той же функциональной сигнатурой, что и интерфейсный метод, размещен в структуре (UserFnUsersFn...),а такжеXxxInvokedСледует ли вызывать идентификатор для отслеживания вызовов методов. Этот подход фактически сочетает в себе mock и stub:Внутри фиктивного объекта поместите функциональные переменные, которые можно заменить тестовыми функциями.(UserFn UsersFn...). Мы можем вручную заменить реализацию функции в нашей тестовой функции в соответствии с потребностями теста.

//mock与stub结合的方式
func TestUserNotFound(t *testing.T) {
	userNotFound := &UserService{}
	userNotFound.UserFn = func(id int) (*myapp.User, error) { //<--- 设置UserFn的期望返回结果
		return nil,errors.New("not found")
	}
	//后续业务测试代码...
	
	if !userNotFound.UserInvoked {
		t.Fatal("没有调用User()方法")
	}
}


// 常规mock方式
func TestUserNotFound(t *testing.T) {
	userNotFound := &userNotFound{} //<---结构体方法已经决定了返回值
	//后续业务测试代码
}

Комбинируя моки со стабами, можно не только динамически изменять реализацию в тестовом методе, но и отслеживать вызов метода.Приведенный выше пример отслеживает только вызов метода.На практике, при необходимости, мы также можем отслеживать вызов метода, время и даже порядок вызова методов:

type UserService struct {
    UserFn      func(id int) (*myapp.User, error)
    UserInvoked bool
    UserInvokedTime int //<--追踪调用次数
    

    UsersFn     func() ([]*myapp.User, error)
    UsersInvoked bool

    // 其他接口方法补全..
    FnCallStack []string //<---函数名slice,追踪调用顺序
}

// User调用mock实现, 并标记这个方法为已调用
func (s *UserService) User(id int) (*myapp.User, error) {
    s.UserInvoked = true
    s.UserInvokedTime++ //<--调用发次数
	s.FnCallStack = append(s.FnCallStack,"User") //调用顺序
    return s.UserFn(id)
}

Но в то же время мы также обнаружим, что наша фиктивная структура более сложна, а стоимость обслуживания также увеличивается. У каждого из двух стилей mock есть свои преимущества.В любом случае, помните, что в программной инженерии нет серебряной пули.Достаточно выбрать подходящий метод для соответствующего сценария. Но в целом комбинация mock и stub — действительно хорошая идея для тестирования, особенно когда нам нужно отслеживать информацию, например, была ли вызвана функция, количество вызовов, порядок вызовов и т. д. mock+stub будет наша альтернатива Второй выбор. Например:

//缓存依赖
type Cache interface{
	Get(id int) interface{} //获取某id的缓存 
	Put(id int,obj interface{}) //放入缓存
}

//数据库依赖
type UserRepository interface{
	//....
}
//User结构体
type User struct {
	//...
}
//userservice
type UserService interface{
	cache Cache 
	repository UserRepository
}

func (u *UserService) Get(id int) *User {
	//先从缓存找,缓存找不到在去repository里面找
}

func main() {   
	userService := NewUserService(xxx) //注入一些外部依赖
	user := userService.Get(2) //获取id = 2的user
}

теперь проверитьuserService.Get(id)Поведение метода:

  1. Вы все еще проверяете базу данных после попадания в кеш? (Не следует проверять снова)
  2. Будет ли проверяться библиотека в случае промаха кеша?
  3. ....

Такого рода тест очень удобно делать через комбинацию mock+stub.В качестве небольшого упражнения можно попробовать реализовать его самостоятельно.

Передача интерфейса с использованием внедрения зависимостей

Интерфейс необходимо внедрить в структуру посредством внедрения зависимостей, чтобы обеспечить возможность замены реализации интерфейса для теста. Почему? Давайте сначала рассмотрим отрицательный пример, который нельзя проверить следующим образом:

type A interface {   
    Fun1() 
}
func (f *Foo) Bar() {    
    a := NewInstanceOfA(...参数若干) //生成A接口的某个实现    
    a.Fun1()  //调用接口方法
}

После того, как вы усердно поработали над макетом интерфейса A, вы обнаружите, что у вас нет возможности заменить фиктивный объект в методе Bar(). Вот правильное написание:

type A interface {    
    Fun1() 
}
type Foo struct {   
    a A // A接口
}
func (f *Foo) Bar() {   
    f.a.Fun1() //调用接口方法
}
// NewFoo, 通过构造函数的方式,将A接口注入
func NewFoo(a A) *Foo {     
    return &Foo{a: A}
}

В примере мы используем метод передачи параметров через конструктор для внедрения зависимостей (конечно, вы можете сделать это и в виде сеттера). Во время теста вы можете пройтиNewFoo()метод передает наш фиктивный объект в*Foo.

Обычно мы будемmain.goвнедрение зависимостей в

в заключении

После долгих разговоров кратко резюмируем несколько ключевых шагов модульного тестирования:

  1. Определите зависимости (сеть, файлы, незавершенные функции и т. д.)
  2. Абстрактные зависимости в интерфейсы
  3. существуетmain.goВнедрить интерфейс, используя внедрение зависимостей

Теперь, когда у нас есть общее представление о модульном тестировании, если вы можете выполнить небольшие упражнения в тексте, поздравляем, вы поняли, как проводить модульное тестирование, и успешно сделали первый шаг. В следующей статье будет представлена ​​структура тестирования gomock для повышения эффективности тестирования.