Go, я создал: пишем тесты на Allure-Go

Привет, Хабр! Вы можете помнить меня по предыдущей статье про Allure-Go, в которой мы коснулись самой макушечки нашей скромной наработки. Сегодня же мы накидаем пару тестов с нуля, разберём подробно примеры и посмотрим, чего же нам удалось в итоге добиться.

Много коммитов утекло с того момента, когда мы с вами общались в прошлый раз. Вышло обновление 0.5, которое привнесло множество изменений, в том числе и в интерфейсах, а также обновление 0.6, которое добавило поддержку test plan из TestOps. Более подробно об обновлениях написано в Release Notes.

bf1b60239bff9b1254be5be8ef58447e.jpg

С чего начать?

Первым делом нужно установить зависимости.

go get github.com/ozontech/allure-go/pkg/allure
go get github.com/ozontech/allure-go/pkg/framework

Теперь мы должны прикинуть, в каком конкретно виде нам понадобятся тесты. В Allure-Go существует несколько вариантов хранения и запусков тестов:

  • Runner из пакета framework/runner, он позволяет запускать единичные тесты. Подробнее можно почитать в Readme,

  • suite — набор тестов, позволяет объединить тесты по бизнес-логике или по общему фактору.

Сегодня мы будем говорить о suite (иначе, ей-богу, никакой статьи нам не хватит), однако принципы, изложенные в этой статье, одинаково справедливы как для тестов в раннере, так и для самостоятельных тестов.

Концепция структур в виде тест-комплектов позаимствована из фреймворка testify. 

de5095990ed362cfc3d974aa47705cf1.gif

Тестовый suite — это аналог тест-класса из JUnit/TestNG. Идея проста: мы имеем объект, методами которого являются тесты. Во время запуска тестов мы передаём экземпляр структуры в исполняющий метод — и он в свою очередь запускает методы структуры как обычные тесты.

Напишем самый простой тест

Ух, звучит, конечно, не так просто, как идея, но на практике всё намного проще.

Вот простой пример:

package test

import (
	"testing"

	"github.com/ozontech/allure-go/pkg/framework/provider"
	"github.com/ozontech/allure-go/pkg/framework/suite"
)

type MyFirstSuite struct {
	suite.Suite
}

func (s *MyFirstSuite) TestMyFirstTest(t provider.T) {
}

func TestSuiteRunner(t *testing.T) {
	suite.RunSuite(t, new(MyFirstSuite))
}

Давайте разберёмся, в чём же соль.

type MyFirstSuite struct — структура, которой суждено хранить в себе наши тесты. Чтобы структуру можно было использовать как тест-сьют, необходимо расширить её с помощью структуры suite.Suite.

func (s *MyFirstSuite) TestMyFirstTest(t provider.T) — наш тест-болванка. 

Важно! Обратите внимание, что тест принимает в качестве аргумента интерфейс provider.T. Это наш основной инструмент работы с тестами, мы вернёмся к нему чуть позже. 

func TestSuiteRunner(t *testing.T) — функция запуска тестов. Поскольку Allure-Go является обёрткой над библиотекой testing, нам требуется получить тестовый контекст *testing.T. Для нас эта функция является отправной точкой в запуске тестов. 

suite.RunSuite(t, new(MyFirstSuite)) — метод, запускающий сьюты.

Note: по умолчанию в Allure-отчёте в качестве имени сьюта будет использоваться имя структуры. Однако можно запустить тест-комплект с помощью метода suite.RunNamedSuite и передать то имя сьюта, которое больше нравится.

5de50720a81f3a09e40ade96457eb9ad.png

Вы можете справедливо заметить: «Друже, ты говорил, что эта штука похожа на JUnit/TestNG. А как же тут с before/after-хуками?» На что я не менее справедливо отвечу: «Всё тут с ними отлично». Давайте расширим наш пример:

package test

import (
	"testing"

	"github.com/ozontech/allure-go/pkg/framework/provider"
	"github.com/ozontech/allure-go/pkg/framework/suite"
)

// Структура сьюта
type MyFirstSuite struct {
	suite.Suite
}

// Сработает один раз перед запуском сьюта
func  (s *MyFirstSuite) BeforeAll(t provider.T) {
}

// Сработает один раз после того, как все тесты завершатся
func (s *MyFirstSuite) AfterAll(t provider.T) {
}

// Будет срабатывать каждый раз перед началом теста
func  (s *MyFirstSuite) BeforeEach(t provider.T) {
}

// Будет срабатывать каждый раз после окончания теста
func (s *MyFirstSuite) AfterEach(t provider.T) {
}

func (s *MyFirstSuite) TestMyFirstTest(t provider.T) {
}

func TestSuiteRunner(t *testing.T) {
	suite.RunSuite(t, new(MyFirstSuite))
}

Выглядит знакомо, не так ли?  

e17fcd0d7c8b0a660217004fadbef50a.jpg

Однако есть некоторые неочевидные особенности нашей имплементации BeforeEach:

  1. Если вы инициализируете некоторые данные в структуре сьюта в методе BeforeEach и ваши тесты бегают параллельно, то вы, скорее всего, столкнётесь с race condition. Этого можно избежать, например, с помощью инструментов синхронизации или мьютексов либо инициализировав все нужные данные в BeforeAll.

  2. В BeforeEach можно проставлять общие для всех тестов теги и лейблы для Allure. Например:

func  (s *MyFirstSuite) BeforeEach(t provider.T) {
	t.Epic("My Epic")
	t.Feature("My Feature")
	// и так далее
}

Тестовый контекст provider.T

Итак, с запуском сьютов разобрались. Теперь давайте погрузимся в сами тесты и разберёмся, какие возможности нам даёт provider.T. Для начала накидаем пару ассертов:

func (s *MyFirstSuite) TestMyFirstTest(t provider.T) {
	test := "test"
	t.Require().NotNil(test)
	t.Require().Equal(test, "test")
}

и заглянем в отчёт:

9dd45686e07bfa40bfdd0dda1699def6.png

Как видно из скриншота, оба ассерта подсветились в нашем репорте.

Note: Allure-Go поддерживает паттерн Soft Assert в виде t.Assert (). Разница в том, что t.Require () сразу уронит тест, а t.Assert () позволит ему дойти до конца.

Отлично! Но хотелось бы сгруппировать проверки в общий шаг (allure.Step), не так ли? В этом нам поможет метод t.WithNewStep(string, provider.StepCtx, …allure.Parameter):

func (s *MyFirstSuite) TestMyFirstTest(t provider.T) {
	test := "test"
	t.WithNewStep("My first step", func(stepCtx provider.StepCtx) {
		stepCtx.Require().NotNil(test)
		stepCtx.Require().Equal(test, "test")
},  allure.NewParameter("time", time.Now()))
}

Note: provider.StepCtx — это интерфейс, который практически во всём похож на provider.T. Такая заморочка с разделением интерфейсов нужна, чтобы прослеживать вложенность шагов во время исполнения.

Ну и было бы супер, если бы мы сохраняли какой-то параметр, например время (а почему бы и нет?). 

Глянем, что же у нас в итоге получается:

7f0c96d5fda0eee6be39bed5689d862f.png

Совсем другое дело :)

Итого, provider.T позволяет:

  • проставлять Allure-лейблы (Feature, Epic, Severity, Tag, etc),

  • размечать тесты на шаги (Step, WithNewStep, WithNewAsyncStep),

  • получать доступ к обёрнутым шагами ассертам (Require, Assert),

  • управлять поведением теста (XSkip, Parallel, Fail, etc).

Note: Полный список возможностей описан в невероятно душной доке: provider.T и provider.StepCtx.

А что же параметризация?

Вот тут на данный момент нет красивого решения даже у нас. Давайте разберём пример на основе нашего первого теста:

func (s *MyFirstSuite) TestMySecondTest(t provider.T) {
	test := "test"
	testData := []string{"test0", "test1", "test2"}
	for idx, text := range testData {
		t.Run(text, func(t provider.T) {
			data := fmt.Sprintf("%s%d", test, idx)
			t.WithNewStep("My First Step", func(sCtx provider.StepCtx) {
				sCtx.Require().NotNil(text)
				sCtx.Require().Equal(data, text)
			}, allure.NewParameter("time", time.Now()))
		})
	}
}

Да, нужно крутить цикл — по-другому пока никак. Но мы сейчас работаем над решением этой проблемы :)

Как же это будет выглядеть в отчёте?

c604bb5fa5e08c76a04f2fb1a3038b94.png

Да, отлично выглядит! Обратите внимание: имя родительского теста выступает в качестве лейбла Suite для параметризованных тестов, а сьют — в качестве лейбла ParentSuite.

Важно: На скриншоте видно TestMySecondTest в отчёте. Для отключения опции создания отчёта у конкретного теста нужно вызвать метод SkipOnPrint из структуры provider.T.

Для такого теста репорт генерироваться не будет. Автоматически этого не происходит по одной важной причине: в Go есть поддержка вложенных тестов, и нам не хотелось бы терять эту функциональность.

Не забудем про XSkip

Почти всё!

Давайте теперь разберём такую штуку, как XSkip. Итак, представьте себе ситуацию: тёплый вечерок, вы лампово потягиваете старый добрый «Портвейн 777» банановый смузи и в двадцатый раз пересматриваете My Little Pony видеолекцию по C++. Тут вам прилетает уведомление в рабочий чат:  

%QAName%! У тебя тест опять отвалился, выкатиться не можем.

И вы вспоминаете, что багу по этому тесту вы завели ещё в прошлый спринт, вчера разработчик взял её в работу, а выкатить обещал только завтра. Что же делать, чтобы и ребятам помочь, и не забыть про этот тест?

На помощь придёт наш t.XSkip()! Давайте рассмотрим пример:

func (s *MyFirstSuite) TestMySecondTest(t provider.T) {
	var test string
	t.Require().Equal("test", test)
}

1ed4619f3ad6f2dbb43fd430e3278c97.png

Тест ожидаемо упал. Что же делать?

func (s *MyFirstSuite) TestXSkip(t provider.T) {
	var test string
	t.XSkip()
	t.Require().Equal("test", test)
}

b8ddf14cf183254f9b63673d204c4f32.png

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

Note:  внимательный читатель заметил, что XSkip очень похож на декоратор xfail из pytest. Однако, в отличие от xfail, XSkip пропускает тест, а не «зеленит» его. 

Параллельность в тестах

8158430d35130f6a2e467ed89b8d1e83.jpg

И снова вы можете спросить: «Друже! А что же с асинхронностью? Ведь testify так и не победили эту проблему. А в прошлый раз, когда ты графоманил писал увлекательную статью про Allure-Go, прелестная @Tan_tan задала тебе вопрос про параллельность в тестах, и ты сказал, что проблема решена лишь частично». На что отвечу: «Мы-таки победили. Потом, кровью, эмоциональным выгоранием, слезами…, но победа оказалась в наших руках. Гордо можем заявить: Allure-Go полностью поддерживает асинхронные запуски без каких-либо но, условностей и тому подобной шелухи».

Note: то, как мы обошли проблему параллельности в тестах, заслуживает отдельного и очень развёрнутого поста. Как-нибудь обязательно об этом расскажу.

Давайте рассмотрим наши чудесные тесты в сьюте и сделаем их параллельными:

func (s *MyFirstSuite) TestMyFirstTest(t provider.T) {
	test := "test"
	t.Parallel() // именно этот метод вызывает параллельность
	t.WithNewStep("My First Step", func(sCtx provider.StepCtx) {
		sCtx.Require().NotNil(test)
		sCtx.Require().Equal(test, "test")
	}, allure.NewParameter("time", time.Now()))
}

func (s *MyFirstSuite) TestMySecondTest(t provider.T) {
	test := "test"
	t.Parallel()
	for idx, text := range []string{"test0", "test1", "test2"} {
		t.Run(text, func(t provider.T) {
			testText = text // обязательно сохраняйте при параллельном запуске локальную переменную для параметризованных тестов во избежание race condition
			data := fmt.Sprintf("%s%d", test, idx)
			t.Parallel()
			t.WithNewStep("My First Step", func(sCtx provider.StepCtx) {
				sCtx.Require().NotNil(testText)
				sCtx.Require().Equal(data, testText)
			}, allure.NewParameter("time", time.Now()))
		})
	}
}

c4f5ec32162101f6796bb973e7f0b0fc.png

Но и это ещё не всё. Вы также можете использовать t.WithNewAsyncStep для запуска асинхронных шагов. Они будут выполняться параллельно с основным потоком вашего теста. Для более точного контроля за их исполнением рекомендуется использовать sync.WaitGroup или channel, однако, если вы про них забудете, тест всё равно не будет считаться завершённым, пока все асинхронные шаги не подойдут к концу, и дождётся окончания всех асинхронных процессов, запущенных шагами.

Давайте рассмотрим пример:

func (s *MyFirstSuite) TestMyFirstTest(t provider.T) {
	test := "test"
  wg := sync.WaitGroup{} // инициализируем WaitGroup для отслеживания работы асинхронного теста
	t.Parallel() // именно этот метод вызывает параллельность
  wg.Add(1) // добавляем единичку к дельте ожидания (если дельта равна 0, то ждать мы перестаём)
	t.WithNewAsyncStep("My First Step", func(sCtx provider.StepCtx) {
              defer wg.Done() // не забываем отпустить нашу единичку после завершения функции
		sCtx.Assert().NotNil(test)
		sCtx.Assert().Equal(test, "test")
	}, allure.NewParameter("time", time.Now()))
  wg.Wait() // ну и наконец ждём
}

Важно! Крайне не рекомендуется использовать t.Require() с WithNewAsyncStep по причине того, что testing.T.FailNow() грубо и бесцеремонно закрывает горутину теста через runtime.goexit, которая в свою очередь убивает все родительские горутины. Могут потеряться данные ваших шагов. Во избежание конфуза используйте t.Assert().

Выводы

На сегодня у меня, пожалуй, всё. Примеры кода из статьи вы можете найти здесь.

В дополнение хочется отметить недавний релиз библиотеки CUTE, посвящённой тестированию HTTP, в основе которой лежит Allure-Go, и статью о ней авторства @siller174.

Лучше, чем наш новый и красивый Readme, о тонкостях Allure-Go не расскажет никто.

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

Спасибо за внимание, берегите себя, скупайте золото и оставайтесь на связи!

© Habrahabr.ru