Golden tests are like unit tests, except the expected output is stored in a separate file. I learned about them in 2010 from Max Grigorev at ZuriHac.
Testing in Go: Golden Files
Hardcoding the expected values in an assertion is a straightforward approach in testing. Most of the time, we know the expected output of the unit under test, so simply adding the raw value to the assertion works well.
Things can get tricky when we are testing a unit whose output is cumbersome to hardcode. The straightforward remedy is to extract this cumbersome value to a file that we can then read and compare the output of the unit under test to the output of the file.
In Go, we call such files golden files. Golden files contain the expected output of a test. When tests run, they read the contents of the golden file and compare it to the output of the unit under test.
As always, let’s make our lives easier and discuss golden files using an example.
Book reports #
Imagine you own a small library or a book store. Your inventory software exposes an API where you can get the list of books in said inventory. You want a program that will take all that data and format it in a Markdown table. Maybe you want to print this table or send it to your accountant. The possibilities are endless!
Golden тесты во Flutter
Let’s see a simulation of such a program:
In the report package, we have a single function Generate which generates the report Markdown table. It gets all the books from the books.Books package, iterates over them and uses the rowTemplate to generate each of the rows of the table. In the end, it returns the whole Markdown table as in a single string.
Let’s take a quick peek at the books package:
The books package contains just the list of the books in our fake inventory.
If we ran the code we would get the following output:
Given that this blog is generated using Markdown, here’s the table rendered:
Hooked | Nir Eyal | Portfolio | 256 | 978-1591847786 | 19 |
The Great Gatsby | F. Scott Fitzgerald | Wildside Press | 140 | 978-1434442017 | 12 |
Then She Was Gone: A Novel | Lisa Jewell | Arrow | 448 | 978-1784756260 | 29 |
Think Like a Billionaire | James Altucher | Scribd, Inc. | 852 | 978-1094400648 | 9 |
Not the prettiest table in the world, still a good overview of the inventory. Now that we have the report in place let’s see how we can test it.
Testing our reports #
Testing the Generate function is straightforward, using the table-driven approach:
The formatting of the expected output is rather hard to scan. That’s because our test cases test the actual output of the table, which is a string with a particular format.
Even worse, in cases when these inconvenient strings are hardcoded, a failing test can be hard to debug if there’s a mismatch in leading or trailing whitespace. Consider this case, for example:
While the want ed and the got ten values look identical, the test is failing because one of the values has trailing whitespace. To avoid hardcoding such outputs in our tests, we resort to using golden files.
Converting to golden files #
To use the golden files, we need to extract the tables to a separate file in the testdata directory. If the testdata directory is foreign to you: it is a directory that the go build tool will ignore when building the binaries of your programs. You can read more about it in my post on fixtures in Go.
Using this approach, we want to move the two table outputs to two different files:
- testdata/empty_inventory.golden, and
- testdata/with_inventory.golden
Both of them will have the corresponding output of the test cases:
Now, let’s change up our test cases to use the golden files to compare the outputs:
The notable changes are on the highlighted lines above:
- we add a golden attribute to each test case, representing the name of its golden file
- for each of the test cases, we open the respective golden file and read all of its contents
- we use the contents to compare the expected ( want ) and the actual ( got ) values
This approach removes hardcoded strings from our tests, and it comes with two important features:
First, golden files are coupled to the test cases (a.k.a. the inputs). That means that if the inputs change, the output will change, so you will have to update the contents of the golden file. Also, if you add another test case, you will probably have to create a golden file for it. If you forget to create it, your tests will fail — which is a good thing!
Second, golden files contain the expected outcome of a test case. So, if the implementation of the code under test changes, your test will fail — which is also a good thing! But that also means our golden files will be out of date, and we have to update them.
Keeping our golden files up to date is a caveat that we always have to keep in mind. As our applications evolve and their functionalities change, the golden files will have to follow suit. If we’re working on a project with many golden files, keeping them up to date can be a frustrating exercise. Therefore it’s often a good idea to automate this, so it becomes an effortless task.
Here’s a straightforward way to automate the golden file updating.
Keeping our golden files up to date #
The whole idea here is, after any change of the implementation or the test inputs, to update the golden files saving ourselves time from copy-pasting outputs to files.
The usual approach in the wild is to provide a command-line flag, usually -update, which you can use with the go test tool:
The update flag will be a bool that we will use in our function that will read and write to the golden files.
Before Go v1.13, the test’s init function invoked flag.Parse(). Since Go v1.13, the TestMain function should invoke the flag.Parse() function. This change of behavior is due to changes introduced in Go 1.13, where the testing package internally parses the flags before it runs the tests. Consequently, if we would skip the TestMain function, go test would not recognize the -update flag.
(You can read more about the change in this Github issue.)
If you are unsure about the meaning of the TestMain function, you can read more about it in my article on the topic here.
Let’s see the rest of the test file and the notable changes:
The significant changes are in the highlighted lines above.
The goldenValue function takes the goldenFile name as an argument, with the actual value of the test returned by the function under test and with the update bool passed from the flag.
Then it takes three steps:
- It opens the golden file in an R/W mode.
- If the update flag is true, it will update the golden file with the contents saved in the actual argument and return it.
- If the update flag is not set, it will continue to read the contents of the golden file and return them as a string.
If any of the reading or writing steps fail, it will crash the test and stop further test execution.
Let’s test this in action. First, the usual, flag-less run of go test:
Now, let’s change our implementation. We will remove the publisher column from the report that will make our tests fail because the golden file expects the Publisher column to be present:
The difference in the two failing tests is noticeable – we’re missing the column. Since we know that this is the new behavior of the report going forward, we only want to update the golden files. Let’s add the -update flag:
Nothing special, only passing tests? Let’s inspect the contents of the golden files:
Voilà! The using the -update flag, we removed the “Publisher” from our golden files, and the tests pass.
Few words of caution #
While the golden files are a simple technique that improves our tests’ legibility, they come with a few caveats.
It’s important to remember that you have to version these files. They are part of the project, and if you do not check them in your version control, your test suite will fail the moment it’s run in continuous integration.
Also, like fixtures, you should keep them in your testdata directory. The go build tool ignores any files in a testdata directory, and it excludes these files when building the binary. While we are comparing golden files and fixtures, there is another similarity between the two — the test files are not self-sufficient. This means that you will have to know the contents of another file to understand what is the expected outcome of your tests.
Furthermore, parsing the golden files can be funny (or frustrating), depending on their content. Be careful when you’re storing content like the one I showed in the example above. If the golden file has lots of whitespaces, new lines or indentation, parsing, and comparing it to other strings can be annoying to debug.
Lastly, the -update flag has an important caveat: it must be available in all packages of your project to function. If you would like to be able to run go test./. -update to update all golden files in your project, all packages in the project must know of -update.
Even if there’s one package that is not aware of -update, running the tests will error out:
Such a behavior is because my Go module has more than one package in it, while only one of them ( report ) knows of -update ’s existence.
In general, the benefits you get with golden files are great, but I suggest treading carefully, given the caveats mentioned above.
Golden tests are like unit tests, except the expected output is stored in a separate file. I learned about them in 2010 from Max Grigorev at ZuriHac. Let’s say you want to test Python’s json module. One way to do that would be to encode an object and compare the result to a reference string: import json.
Introduction to golden testing
Golden tests are like unit tests, except the expected output is stored in a separate file. I learned about them in 2010 from Max Grigorev at ZuriHac.
Let’s say you want to test Python’s json module. One way to do that would be to encode an object and compare the result to a reference string:
Alternatively, you could create a file with contents
and read it to know the expected output:
The file example1.json is called a golden file.
Here are some advantages of golden tests over ordinary unit tests:
If the expected output is large in size, it may be impractical to put it inside the source code.
No need to escape quotes or binary data in the expected output.
When you add a new test, your testing framework can generate the missing golden file from the current output of the function.
It is best if you can write down the expected output without looking at the actual output, but it is not always possible. The output may be too big to type it character by character, or it may be hard to predict. For instance, in the json example, you couldn’t tell in advance whether there would be spaces between array elements or not. So often what you do is launch an interactive interpreter (if your language of choice even has one), run the function, and then copy-paste its output into the test code.
This process can be easily automated if you use golden files.
The expected output can be automatically updated.
Say you changed your json module to replace some of the spaces with newlines to make the output more aesthetically pleasing. You have 40 test cases that need updating. Can you imagine doing this by hand?
With golden tests, you can tell your test framework to update all golden files from the current outputs, then check git diff to ensure that all changes are valid, and commit them.
If some of your tests suddently started failing, you can use diff or other such tools to compare the golden file to the actual file and figure out what exactly changed. Perhaps your testing framework could even show the diff automatically on test failure?
While advantages 1-2 are automatic, 3-5 require special support from your testing framework. The rest of this article will be focused on a Haskell testing framework tasty and its add-on package for golden tests, tasty-golden.
Basic usage
To illustrate how tasty-golden works, consider this yaml-to-json conversion module:
Because JSON contains quotes and YAML spans multiple lines, it is not very practical to store them as string literals in the source code file. Instead, you will keep them both in files.
Note that the name “golden file” only refers to the file containing the output, not the input. There is no requirement that the input is stored in a file or that there even is any “input” at all; but in practice it is often convenient to store them both in files so that there is an input file for every output file and vice versa.
This is all the code you need to support one, two, or a thousand test cases. When run, this code will:
- find all.yaml files in the current directory
- for each.yaml file, construct a golden test that evaluates yamlToJson on the input read from file and compares the result to the golden file, which has the name and the.json extension
- put all individual tests in a test group and pass it to defaultMain for execution
To see how this works in practice, create an input file, fruits.yaml, with the following contents:
Now run your test suite (note: in a proper cabalized project, you’d run cabal test or stack test instead):
tasty-golden realized that this is a new test case because the golden file was absent, so it went ahead and initialized the golden file based on the function’s output. You can now examine the file to see if it makes sense:
If you are happy with it, check in both input and output files to git. This is important so that your collaborators can run the tests, but it also helps when dealing with failing tests, as you’ll see next.
Dealing with test failures
Occasionally, your tests will fail. A test that cannot fail is a useless test.
A golden test fails when the actual output does not match the contents of the golden file. You then need to figure out whether this is a bug or an intentional code change.
Let’s say you decide that the output of yamlToJson should end with a newline.
The new function definition is
Now run the test suite:
Ok, this is not very helpful. There are two main ways to get better diagnostics. One is to use the goldenVsStringDiff function as an alternative to goldenVsString. This will include the diff right in the tasty output.
But my preferred workflow is to use git for this. First, rerun the tests and pass the —accept option. This will update the golden files with the new output:
Now, because your golden file is tracked by git, you can examine the differences between the old and new golden files with git diff:
Because this is the change you expected, you can now commit the updated file to git.
This workflow lets you use all the powerful git diff options like —color-words, or even launch a graphical diff tool like kdiff3 with git difftool.
Golden — тест проверяет отдельные виджеты и целый экран. Визуальное представление компонента сравнивается с предыдущими результатами тестов. Итоговая таблица сравнения типов тестирования. Тестируем UI. Покроем тестами готовое приложение, которое писали в рамках статьи « Elementary: новый взгляд на архитектуру Flutter-приложений ». Проверять визуальное представление будем при помощи golden — тестов. Их легко освоить и поддерживать — если правильно соблюдать зависимости. Если вы используете IDEA, добавьте конфигурацию запуска теста для генерации golden. Также генерацию golden — тестов можно выполнить через CLI при помощи команды. flutter test — update-goldens.
Быстро пишем функциональные тесты
Напомню, что традиционно выделяют следующие виды тестов:
- Модульные — они же юнит-тесты. Это тесты изолированные внутри пакета/модуля, которые тестируют отдельные методы классов и помогают при разработке.
- Функциональные — тесты, обращающиеся к пакету/модулю как к чёрному ящику, вызывая только публичные методы.
- Интеграционные — (и их подвиды: end-to-end и сценарные тесты) проводятся в среде приближенной к рабочей, с реальными бд и другими сервисами.
Не вдаваясь в холивары, будем придерживаюсь той точки зрения, что модульные тесты это инструмент для разработки, когда есть сложная логика, которую легко протестировать. Для них редко нужны моки. В них можно и нужно тестировать приватные методы и функции. Они близки к реализации и при рефакторинге их часто приходится переписывать или просто выкидывать. Ориентироваться на них в приёмке кода нужно с той же степенью как на название локальных переменных.
Функциональные же тесты фиксируют внешний интерфейс пакета/модуля, таким образом при рефакторинге они не должны меняться. И при приёмке кода изменения в них должны тщательно проверяться. В таких тестах всё окружение заменяется моками, чтобы проверить краевые сценарии. Именно о них в контексте Go и пойдёт речь.
Интеграционные тесты вообще выделяются в отдельный пакет или даже проект. О них мы поговорим как-нибудь в другой раз.
Где располагаются функциональные тесты?
Если опираться на архитектуру, описанную в статье Чистая архитектура на Go, то основная бизнес-логика сервиса должна располагаться в действиях, в пакете action. (Подробнее о написании самих действий можно почитать в статье Действия в действии.) Каждая команда, предоставляемая сервисом должна по сути 1 к 1 соотносится с действием.
У сервиса могут быть разные интерфейсы, может быть одновременно несколько интерфейсов, например, HTTP и gRPC, или SOA поверх AMQP. Может быть несколько точек входа, например, проект может одновременно выступать и как HTTP-сервис, и собираться в виде утилиты командной строки. За интерфейсы отвечает слой сервисов, в котором происходит обработка параметров запроса с приведением к типам, моделям и интерфейсам проекта (разбор тела HTTP-запроса или преобразование protobuf-модели к внутренней), а также сериализацией ответа. Сама же логика работы сервиса должна оставаться в действиях.
Таким образом, действие не должно знать откуда его вызвали и как были получены аргументы этого вызова (пришли ли они через RabbitMQ или были переданы флагами командной строки). Соответственно функциональность сервиса собрана в пакете action и именно в нём и будем располагать функциональные тесты.
Одной из ключевых особенностей функциональных тестов является то, что они должны относится к действиям как чёрным ящикам. Для того, чтобы избежать соблазна обратиться к приватным функциям или методам, отделим тесты в отдельный пакет. В Go для этого не нужно создавать отдельную папку, достаточно в заголовке файла теста прописать другое имя пакета:
Кроме того, можно одновременно в пакете держать и юнит-тесты, которые будут относится к пакету action и иметь доступ к приватным полям и методам. Например, пусть у нас есть действие Login, расположенное в файле login.go, тогда юнит тесты можно расположить в файле login_unit_test.go, оставив заголовком
а функциональные тесты положить в файл login_func_test.go, назвав пакет с суффиксом _test. Тогда пакет действий будет внешним по отношению к функциональным тестам и его надо импортировать аналогично тому, как это делается в сервисах:
DI. Зависимости и моки
Как упоминалось в предыдущих статьях, все внешние зависимости и побочные эффекты действий должны передаваться с помощью контейнера зависимостей DIContainer, интерфейс которого описывается в пакете action. Например,
Добавим комментарии для автоматической генерации моков:
В большинстве рабочих проектов я использовал gomock для генерации моков, но последнее время стал переходить на minimock в связи с тем, что код для работы с моками оказывается куда лаконичнее.
После этого выполним команду генерации
что должно создать подпакет mock в пакете действий, где будут расположены моки для всех интерфейсов пакета действий.
Далее в тестах моки можно использовать очень просто:
Подробнее в документации к проекту minimock.
Golden-файлы
Частой оказывается ситуация, когда результат действия достаточно большой или действие имеет множество побочных эффектов: изменения в бд, отправленные сообщения в очереди, изменения в кеше и так далее. На каждое изменение можно написать проверку в коде теста. Однако, код самого теста при этом сильно разрастается и его становится тяжело читать. Если в модульных тестах это говорит о проблеме архитектуры, ведь каждый отдельный метод должен иметь очень ограниченный набор изменений, то говоря о функциональных тестах, набор изменений производимый действием в большей степени определяется бизнес-задачей и не может быть изменён (хотя, если действие влияет на слишком большой набор внешних систем, то возможно стоит пересмотреть архитектуру системы).
Рассмотрим действие, которое составляет отчёт по покупкам в интернет магазине. Пусть, к примеру, в отчёте должны выводиться товары, купленные за указанный период в порядке уменьшения доходности. Возможны различные краевые условия, которые могут повлиять на этот отчёт: размер скидки, цена доставки и многое другое. Если мы захотим протестировать все возможные варианты небольшими тестами, разбирающими, конкретный случай, количество тест-кейсов будет расти комбинаторно новым требованиям. Например, надо сделать тест, в котором будет товар со скидкой, а также товар со скидкой и дорогой доставкой. С другой стороны, можно написать тест, который покроет сразу все комбинации, то есть в отчётном периоде будет и товар со скидкой, и с дорогой доставкой, и товар со скидкой и доставкой враз. Но получившийся отчёт будет сложно проверить программно. По сути программа для проверки корректности отчёта будет дублировать логику его составления.
Для решения этой задачи есть подход, называемый golden-файлы. Это текстовые файлы (могут быть и бинарные, но для бэкенда это редкость), в которых содержится референсный результат выполнения тестируемой функции. Тесты, использующие golden-файлы имеют 2 режима:
- создание/обновление golden-файлов;
- сравнение с golden-файлами.
Можно просто положить рядом с тестом текстовый файл, содержащий отчёт, например, в формате json. А в коде теста сравнить результат выполнения действия с данными из файла. Код такого теста будет достаточно короткий и понятный. А корректность отчёта нужно будет проверить вручную один раз, зато потом тест будет выполняться автоматически вплоть до того момента, пока не изменятся бизнес-требования к тестируемому действию. А в тот момент, когда изменятся требования, например, нам надо будет убрать из отчёта возвращённые товары. В этот момент нам надо будет переделать код составления отчёта, после чего запустить тест в режиме обновления golden-файлов, и вручную проверить получившийся результат.
Также изменения в golden-файлах будут видны на код-ревью, ведь эти файлы сохраняются в репозиторий также как код самих тестов, а соответственно при их изменении ревьювер увидит что и как в них изменилось.
Для облегчения работы с golden-файлами в Go есть библиотека gotest.tools/golden, с помощью которой легко сравнивать и обновлять содержимое golden-файлов.
Большой головной болью при тестировании с помощью golden-файлов являются сайд-эффекты, например, если бы наш отчёт содержал дату и время своего формирования, то они бы изменялись при каждом запуске. Именно поэтому все сайд-эффекты должны быть вынесены из действий как зависимости. Например, удобно использовать библиотеку для подмены часов github.com/jonboulle/clockwork, а также не использовать глобальные генераторы случайных чисел. Например, если отчёт должен иметь уникальный идентификатор (UUID), то лучше добавить в контейнер зависимостей генератор идентификаторов:
Сюита. Собираем всё вместе
Итак, функциональный тест обрастает вспомогательным кодом по подготовке моков, по сравнению с референсным выводом. В рамках одной функции всё это будет читаться достаточно тяжело. Кроме того, возможно для некоторых тестов подойдут одни и те же моки, так что хорошо бы их сделать переиспользуемыми. В этом случае оказывается удобно объединить несколько тестов и вспомогательные методы в один класс, так называемую тестовую сюиту. Для этого отлично подходит пакет github.com/stretchr/testify/suite, предоставляющий базовый объект сюиты, который можно расширять под собственные нужды. Тогда тест может выглядеть примерно следующим образом:
Вспомогательные методы и базовая сюита
Многие вспомогательные методы повторяются из теста в тест, например, подготовка логера. С одной стороны можно просто возвращать NopLogger, который просто не пишет логи никуда, с другой стороны может оказаться полезным увидеть логи упавшего сервиса, так что обычно я создаю логер, который пишет в буфер, а в функции TearDownTest я добавляю проверку прошёл тест или нет и, если не прошёл, вывожу буфер на экран.
Также удобно строить моки различных репозиториев сущностей поверх json-файлов со списком этих самых сущностей. Так что метод десериализовать данные из файла на входной объект также используется в подавляющем большинстве тестов. Такие методы можно вынести в базовый класс сюиты, а сюиты тестов наследовать уже от неё:
Выводы
Разобравшись в предложенных инструментах и подходах, а также подготовив базовую сюиту, написание функционального теста на написанное действие займёт всего несколько минут, но повысит уверенность в собственном коде, а также даст поддержку при внесении изменений в будущем.
Golden tests may be executed locally on Linux, MacOS, and Windows platforms. All reference images can be found at Flutter Gold baselines. Some tests may have multiple golden masters for a given test, to account for rendering differences across platforms. The parameter set for each image is listed in each image digest to differentiate renderings. Once you have written your test, run flutter test —update-goldens test /foo/bar_ test.dart in the flutter package directory (where the filename is the relative path to your new test ).
- https://ieftimov.com/posts/testing-in-go-golden-files/
- https://ro-che.info/articles/2017-12-04-golden-tests
- https://vporoshok.me/post/2019/08/quick-test/