В этой части мы узнаем, как тестировать реализацию GORM, но используя SQL Mock вместо реальной базы данных. Мы также узнаем, как протестировать разделение слоев с помощью GoMock. Файлы размещены здесь для справки: https://github.com/hariesef/myschool.
Ссылка на предыдущую главу: https://hariesef.medium.com/go-e2e-tutorial-part-4-twirp-rpc-c7bd8eeae925
В предыдущей главе мы узнали о реализации методов Twirp для Student Repo. В целях обучения мы будем тестировать методы с помощью модульных тестов, использующих SQL Mock.
Реализация Twirp Server — это, по сути, прокси для реального сервиса. Во время реального проекта я бы не рекомендовал создавать для них модульные тесты. Внутри них не должно быть бизнес-логики, и им нужно только перенаправить запрос в сервис или репозиторий, поэтому модульное тестирование здесь не имеет реальной пользы от качества кода. Причина, по которой мы сейчас проводим здесь модульное тестирование, заключается только в учебном пособии по sql. Которые, в идеале, должны быть помещены в служебную главу. Но в этой серии руководств позже наша служба, к сожалению, будет подключаться к NoSQL DB/Mongo.
var _ = Describe("Student Repo Implementation", func() { var mock sqlmock.Sqlmock var server *internalRPC.StudentRPCServer BeforeEach(func() { var mockDb *sql.DB mockDb, mock, _ = sqlmock.New() dialector := postgres.New(postgres.Config{ Conn: mockDb, DriverName: "postgres", }) db, err := gorm.Open(dialector, &gorm.Config{}) Expect(err).ShouldNot(HaveOccurred()) studentRepoImpl := sqLiteStudent.NewRepo(db) repo := &repositories.Repositories{ StudentRepo: studentRepoImpl, } server = &internalRPC.StudentRPCServer{Repo: repo} })
Пожалуйста, проверьте полное содержимое файла здесь: https://github.com/hariesef/myschool/blob/master/internal/controller/rpc/student/student_rpc_impl_test.go
Пояснение к фрагменту выше:
- макет и сервер создаются глобально, потому что они будут использоваться в каждом тестовом примере.
- Если вы еще помните, наша реализация студенческого репозитория использует GORM, что означает, что мы можем использовать любой драйвер SQL. В этом примере вместо этого мы используем драйвер postgres.
- Однако вместо этого драйвер подключается к mockDb.
Фактический тестовый пример будет выглядеть так:
It("tests creating a user", func() { mock.ExpectBegin() query := regexp.QuoteMeta("INSERT INTO \"students\" (\"created_at\",\"updated_at\",\"deleted_at\",\"name\",\"gender\")") rows := sqlmock. NewRows([]string{"uid"}). AddRow(777) mock.ExpectQuery(query).WillReturnRows(rows) mock.ExpectCommit() student, err := server.Create(context.TODO(), &pkgRPC.StudentParam{Name: "Mama Ishar", Gender: "F"}) Expect(err).ShouldNot(HaveOccurred()) Expect(student.GetId()).To(Equal(int32(777))) Expect(student.GetName()).To(Equal("Mama Ishar")) Expect(student.GetGender()).To(Equal("F")) Expect(student.GetCreatedAt()).Should(BeNumerically(">", 1600000000)) })
Объяснение приведенного выше модульного теста:
- Переменная mock — это место, где мы устанавливаем поведение работы с базой данных.
- Нашей основной операцией является server.Create(). Когда это запустится, возникнет вопрос, какие выполняются базовые операции БД?
- Ответ на это: GORM фактически выполнит начало транзакции, затем запустит запрос для вставки в таблицу студентов, а затем закроет его операцией фиксации.
- Вот почему заранее мы говорим mock:эй, mock, будьте готовы, вам нужно будет запустить Begin(), а затем, если есть запрос на вставку строки в таблицу студентов, вернитесь назад строка, содержащая uid. Затем, наконец, вы должны запустить Commit()
Работа с DB Mock — это метод проб и ошибок. Сначала мы никогда не знали бы, какие фактические операции будут выполняться.
Фактический шаг в создании этого тестового примера на самом деле выглядит следующим образом:
Начните с запуска непосредственно метода:
mock.ExpectQuery("insert query") student, err := server.Create(context.TODO(), &pkgRPC.StudentParam{Name: "Mama Ishar", Gender: "F"})
Затем просто запустите гинкго, мы ожидали увидеть ошибку, но ошибка сообщит нам, что происходит:
call to database transaction Begin, was not expected, next expectation is: ExpectedQuery => expecting Query, QueryContext or QueryRow which: - matches sql: 'insert query'
Мы ожидаем, что будет запущен фиктивный запрос, называемый «вставить запрос», но на самом деле GORM выполняет транзакцию Begin().
Хорошо, давайте изменим наш тестовый пример, чтобы он соответствовал началу:
mock.ExpectBegin() mock.ExpectQuery("insert query") student, err := server.Create(context.TODO(), &pkgRPC.StudentParam{Name: "Mama Ishar", Gender: "F"})
Тогда у нас будет другая ошибка на гинкго:
Query: could not match actual sql: "INSERT INTO "students" ("created_at","updated_at","deleted_at","name","gender") VALUES ($1,$2,$3,$4,$5) RETURNING "uid"" with expected regexp "insert query"; call to Rollback transaction, was not expected, next expectation is: ExpectedQuery => expecting Query, QueryContext or QueryRow which: - matches sql: 'insert query'
В этой недавней ошибке гинкго на самом деле говорит нам, что реальная операция выполнена так: ВСТАВИТЬ В «студентов» («создано_в», «обновлено_в», «удалено_в», «имя», «пол») ЗНАЧЕНИЯ ($1, $2, $3 ,$4,$5) ВОЗВРАЩЕНИЕ «uid»
Таким образом, вместо фиктивного запроса «вставить запрос» гинкго ожидает, что мы получим эту длинную строку в качестве аргумента ExpectQuery().
Обратите внимание, что совпадение строки на самом деле соответствует регулярному выражению. Поэтому нормальное экранирование символов не будет работать. Для этого лучше использовать помощник из QuoteMeta.
mock.ExpectBegin() query := regexp.QuoteMeta("INSERT INTO \"students\" (\"created_at\",\"updated_at\",\"deleted_at\",\"name\",\"gender\")") mock.ExpectQuery(query) student, err := server.Create(context.TODO(), &pkgRPC.StudentParam{Name: "Mama Ishar", Gender: "F"})
Здесь я не помещал полный запрос, потому что достаточно подстроки. От вас зависит, насколько строго вы сравниваете запрос.
Запуск гинкго сейчас даст нам другую ошибку:
Query 'INSERT INTO "students" ("created_at","updated_at","deleted_at","name","gender") VALUES ($1,$2,$3,$4,$5) RETURNING "uid"' with args [{Name: Ordinal:1 Value:1690688985} {Name: Ordinal:2 Value:1690688985} {Name: Ordinal:3 Value:0} {Name: Ordinal:4 Value:Mama Ishar} {Name: Ordinal:5 Value:F}], must return a database/sql/driver.Rows, but it was not set for expectation *sqlmock.ExpectedQuery as ExpectedQuery
Мы сосредоточимся на тексте ошибки «должен вернуть базу данных/sql/driver.Rows» — так что на самом деле ожидается, что запрос вернет строку в качестве результата. Итак, давайте снова изменим тест:
mock.ExpectBegin() query := regexp.QuoteMeta("INSERT INTO \"students\" (\"created_at\",\"updated_at\",\"deleted_at\",\"name\",\"gender\")") rows := sqlmock. NewRows([]string{"uid"}). AddRow(777) mock.ExpectQuery(query).WillReturnRows(rows) student, err := server.Create(context.TODO(), &pkgRPC.StudentParam{Name: "Mama Ishar", Gender: "F"})
Выполнение этого даст нам нашу последнюю ошибку:
all expectations were already fulfilled, call to Commit transaction was not expected
На самом деле он ожидает Commit после запроса. Добавление Commit(), наконец, сделает тестовый пример таким же, как исходный фрагмент во 2-м вверху этой статьи.
Тестирование реализации интерфейса с помощью GoMock
В предыдущей подглаве рассказывается о тестировании с реальным драйвером БД, мы просто изменяем объект БД, чтобы получить пользовательский результат.
Если мы поднимемся на один уровень выше, мы действительно сможем протестировать любую реализацию интерфейса с помощью GoMock. Рассмотрим диаграмму ниже:
Между Сервисом и Репо разделены интерфейсом. С правой стороны, в реализации хранилища мы хотели бы полностью использовать локальную онлайн-БД в наших модульных тестах, а также несколько макетов БД для эмуляции пограничных случаев.
С левой стороны мы не должны создавать экземпляр фактической реализации хранилища, когда тестируем бизнес-логику внутри службы. Мы должны издеваться над реализацией хранилища и их возвращаемыми значениями.
Вернемся к нашему оригинальному файлу интерфейса ученика, который можно найти здесь: https://github.com/hariesef/myschool/blob/master/pkg/model/user_iface.go
В верхней части файла мы найдем эту строку:
//go:generate mockgen -source=user_iface.go -package=mocks -destination=../../internal/mocks/user_iface.go
На самом деле это тег, который используется в Makefile для создания фиктивных файлов на основе интерфейса. Сгенерированный файл будет находиться в папке internal/mocks. Сгенерированные файлы также регистрируются на github. Но в случае, если мы хотим сгенерировать их снова, можно запустить указанную выше команду или, альтернативно, с помощью make go-gen
из базового каталога.
Мок-файл может использоваться модульным тестом для эмуляции результата реализации хранилища.
Все еще говорим об этом тестовом файле, но сейчас мы переходим к следующей тестовой группе. Мы по-прежнему хотим протестировать server.Create() из реализации Twirp, но сейчас мы не будем имитировать БД, а репозиторий.
Describe("Testing student repo functions using Mock of repo", func() { Context("User Creation", func() { It("tests creating a user", func() { repoMock := mocks.NewMockStudentRepo(ctrl) repo2 := &repositories.Repositories{ StudentRepo: repoMock, } server2 := &internalRPC.StudentRPCServer{Repo: repo2} repoMock.EXPECT().Create(gomock.Any(), gomock.Any()).Return(&sqLiteStudent.Student{ Name: "Haries", Gender: "M"}, nil) student, err := server2.Create(context.TODO(), &pkgRPC.StudentParam{Name: "Mama Ishar", Gender: "F"})
Объяснение:
- В предыдущем модульном тесте экземпляр репо был создан из реального пакета, который подключается к Mocked DB.
- В этом тесте репозиторий создается из функции NewMockStudentRepo().
Когда вызывается фактический метод: server2.Create(), мы ожидаем, что репо что-то вернет. Вот почему в предыдущей строке мы делаем repoMock.EXPECT().‹Эта функция вызывается› — которая вызывает функцию Create() из репозитория.
Функция требует два аргумента, но в данном случае мы не заботимся об аргументах, поэтому в качестве аргументов мы ставим (gomock.Any(), gomock.Any()). В будущем вы также можете настроить ответ на основе аргументов, т.е.
- вызов repoMock.EXPECT().Create(A, B).Return(Result-X) пока
- если есть вызов repoMock.EXPECT().Create(A, C).Return(Result-Y) вместо этого
Возвращаемое значение довольно простое, именно то, что описано в интерфейсе. В данном случае это StudentModel. Однако StudentModel — это интерфейс, который не может быть создан, поэтому вместо этого мы должны вернуть структуру реализации: Student{}, которая поступает из хранилища:
import ( ... sqLiteStudent "myschool/internal/storage/sqlite/student"
Другой способ сформулировать возвращаемое значение
В случае, если ответ требует более сложной обработки перед определением значения результата, в качестве альтернативы можно использовать этот способ имитации:
repoMock.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( func(ctx context.Context, args model.StudentCreationParam) (model.StudentModel, error) { studentObject := sqLiteStudent.Student{Name: "Haries", Gender: "M"} return &studentObject, nil })
Приведенный выше код позволяет нам переопределить функцию, и мы можем сделать что-нибудь внутри, прежде чем, наконец, вернуть результат.
Это все для этой главы. Пожалуйста, продолжайте читать следующую часть руководства: https://hariesef.medium.com/go-e2e-tutorial-part-6-service-creation-with-mongodb-d5422ac6c6ee
Большое спасибо за чтение! 🍻 Здоровья!