golang
May 3, 2022

Структура тестов Go, для RSpec-нутых

Для кого статья?

Для Ruby-разработчиков, которые знают что такое хорошие тесты на RSpec (остальных в Go ничего не смутит) и хотят писать тесты на Go соответствующим образом.

Для Go-разработчиков. На Go можно написать хорошие тесты, но почему-то я таких пока не видел 😇. Почему-то даже в крупных и популярных пакетах используют не лучшие подходы.

Документация по тестированию на Go не очень богата и оставляет много вопросов (пока не получил опыта работы с RSpec, они у меня не возникали), ну что же, попытаемся на них ответить.

Немного предыстории

RNDSOFT как-то организовывала Ruby meetup, где я выступал с докладом "Тесты Go глазами Ruby-разработчика" и проводил параллели между тестами в Go и RSpec, рассказывая о трудностях, с которыми сталкиваешься, начиная писать тесты в Go, и как преодолеть их с наименьшей болью.

И вот сейчас у меня дошли руки и я хочу изложить мои мысли текстом, сконцентрировав внимание именно на структурировании тестов.

Что будет?

  1. Посмотрим, что говорит нам документация по поводу того, как нужно писать тесты.
  2. Обратим внимание на то, что документация не даёт нам всех ответов по структурированию тестов.
  3. Поймём, что для структурирования тестов в реальном проекте нам понадобится некое соглашение.
  4. Чтобы не изобретать велосипед, определим, какое соглашение можно взять за эталон и работать с ним.
  5. Разберём функционал, предоставляемый Go, с точки зрения RSpec (только стандартный пакет тестирования. Почему так, поговорим ниже).
  6. Поймём, как в Go решить те же задачи, которые позволяет решить RSpec, но не выходя за парадигму Go.

Чего не будет?

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

  • "Как вообще можно сравнивать RSpec и Go".
  • "Привыкли писать на своём RSpec, так теперь везде вам подавай всё как там? Учитесь писать в парадигме Go".
  • "Go строго типизированный, это вам не Ruby, в нём всё по-другому".
  • "Go строго типизированный, в нём не нужны такие тесты, как на RSpec".

Выдохните, сосчитайте до десяти, заварите себе горячий напиток 😤☕️🙂.

Тут не будет:

  • попыток писать на Go, как на RSpec,
  • сравнения специфический возможностей,
  • сравнения особенностей написания тестов.

Я прекрасно понимаю, что языки разные и у них разные подходы. Но когда разработчик пишет, он должен (но не всегда это понимает) решать ряд стандартных задач. И вот именно о том, как взяв в руки Go, обеспечить тот необходимый минимум, с реализацией которого мы (Ruby-разработчики) справляемся, когда используем RSpec.

Почему не рассматриваем BDD фреймворки языка Go?

RSpec - это BDD фреймворк и было бы логично сравнивать его с BDD фреймворком в Go, например с пакетами Ginkgo или GoConvey, но делать мы этого не будем.

А дело собственно в том, что согласно опросу в телеграм канале Go-go!, который я проводил при подготовке к докладу, можно сказать, что описанные выше пакеты использует примерно 10%-15% разработчиков. Не говоря уже о том, что достичь тех же результатов можно и при помощи стандартного пакета, начиная с Go 1.7 уж точно.

К чему мы привыкли в Rspec?

Тестирование методов

В первую очередь стоит сказать, что это BDD фреймворк и поэтому имеет специфичную для этой парадигмы структуру написания тестов.

Основными элементами теста на Rspec являются блоки:

  • describe - описывает тестируемый класс или метод,
  • context - для описания состояний, окружения, контекста (когда пользователь авторизован, когда пользователь не авторизован),
  • it - тело теста.
describe Store do
  describe '.sum' do
     context 'when summing positive numbers' do
        it { expect(described_class.sum(2, 2)).to eq 4 }
     end

     context 'when summing negative numbers' do
        it { expect(described_class.sum(-2, -2)).to eq -4 }
     end
  end
end

Когда мы описываем метод как .sum или ::sum, это означает, что мы тестируем метод класса (статический метод). Если мы описываем метод как #sum, значит мы тестируем метод инстанса (метод, вызываемый на объекте).

Тестирование функции

А вот тут ясности поменьше.

По сути можно сказать, что RSpec - это фреймворк для тестирования ООП приложений. И в рамках тестирования такого приложения, данный вопрос у вас не возникнет. Так например, шанс, что в Rails приложении вам понадобится сделать нечто подобное, стремится у нулю.

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

В таком случае, если мы тестируем некий скрипт, в котором есть отдельно стоящая функция sum, то мы можем написать тест как-то так и не переживать по этому поводу:

describe 'Test some script' do
  describe '#sum' do
    it { expect(sum(2, 2)).to eq 4 }
  end
end

Тест не потерял в читаемости. Написать некий текст вместо имени тестируемого класса, вполне допустимо. В полноценном приложении (а не в скрипте), подобная ситуации - это редкость и исключительный случай. Так что сделаем маленькое исключение для него и не будем об этом жалеть.

Тестирование приватного метода

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

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

describe Store do
  let(:store) { described_class.new }

  describe '#sum' do
    it { expect(store.send(:sum, 2, 2)).to eq 4 }
  end
end

А как это в Go?

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

RSpec даёт каркас для написания тестов и набор рекомендаций, как всем этим пользоваться. Уже после сюда добавляются соглашения Better Specs, делающие тесты ещё прекраснее.

Go старается быть простым и однозначным (тот же вопрос с фигурными скобками решён весьма однозначно 🙂). Язык даёт нам много хорошего инструментария и простые, но строгие руководства, как с ним работать. И эти руководства не просто строгие, они не гибкие (в рамках официальной документации).

Собственно в этом и кроется ключевая проблема. RSpec с виду не так прост, но практически не оставляет вопросов. Есть описание в документации, есть соглашения, но помимо этого есть гибкость, позволяющая покрывать специфичные случаи. А Go однозначен и строг. И разработчик, оказавшись в ситуации строгости, но недосказанности, не может понять, как ему быть в тех или иных случаях, которые не покрывает документация. Само собой, сложности добавляет отсутствие достаточного опыта в языке и контакта с другими разработчиками Go, чтобы интуитивно понимать допустимые и недопустимые решения.

Тестирование функции

Тут всё на высоте. Всё просто и понятно. Идём читать документацию и

видим там следующий шаблон для написания тестов:

func TestXxx(*testing.T)

По сути, нам показали, как протестировать публичную (начинается с заглавной буквы) функцию.

Следовательно, если в нашем пакете есть публичная функция Sum, то тест для неё будет выглядеть вот так:

func TestSum(t *testing.T) {
	...
}

Собственно, это всё, что нам рассказывает документация касательно именования тестов.

Если в вашем приложении есть только функции, у вас нет структур, а если и есть, то они не содержат поведения, то описанного в документации вам достаточно, можете закрывать статью.

Тестирование приватной функции

??????????????????????????????????

Знаю-знаю, что тестировать приватные функции это не правильно, но в реальности случаются, пускай и редко, ситуации, где лучше сделать это, чем не сделать.

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

В языке Ruby нет приватных функций, есть только приватные методы, поэтому что делать с такой ситуацией, Ruby-разработчикам не очень понятно.

Тестирование метода

??????????????????????????????????

Тоже не понять но как это делать. Нужно разбираться.

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

Тестирование в разных обстоятельствах (в разном окружении)

??????????????????????????????????

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

Как мы помним, в RSpec эту задачу решает блок context. В Go это тоже можно сделать, просто ответ лежит не на поверхности.

Тестирование приватной функции

Если написанное выше, касательно тестирования всего приватного, тебя не успокоило и душа всё ещё горит, можешь написать в комментах "Не тестируйте приватные функции".

Проблема тут в том, что шаблон именования теста, подразумевает, что название функции будет написано с большой буквы, то есть это будет публичная функция.

Поэтому, все варианты, которыми мы можем описать тест для приватной функции sum, буду нарушать соглашение из официальной документации.

func Testsum(t *testing.T) {
	...
}

// или

func Test_sum(t *testing.T) {
	...
}

Так написать конечно можно, но какой вариант выбрать и почему? Как объяснять это другим разработчикам. Ответы будут ниже.

Тестирование метода

Давайте вспомним нашу функцию Sum и то, как мы писали тест для неё:

func Sum(x, y int) int {
	return x + y
}

Но что если Sum - это метод структуры? Можно предположить, что именование теста менять не стоит, и писать надо так же, как сказано в документации применительно к функциям. Это сработает, но глядя на именование теста, Вы не будете понимать, что тестируете. Подобная неоднозначность конечно не приятна, но с ней всё ещё можно мириться.

Трудности начинаются, когда в пакете присутствуют метод и функция с одинаковым именованием, как например в пакете viper.

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

Соглашение по именованию тестов из gotests - решение проблем

Ответы на поставленные выше вопросы и не только на них даёт пакет gotests. Этот пакет используется для автоматической генерации заготовок тестов, для абсолютно всего, что находится в вашем пакете.

Взглянув на то, как он работает, мы можем определить используемые им правила именования и позаимствовать их.

Правила выглядят следующим образом:

TestPubFunc(t *testing.T) Test_privateFunct(t *testing.T) TestPubStruct_PubMethod(t *testing.T) TestPubStruct_privateMethod(t *testing.T) Test_privateStruct_privateMethod(t *testing.T)

В чём плюсы:

  1. Подобное именование решает все заявленные проблемы.
  2. Из названия теста понятно, что тестируется.
  3. Соглашение отражено в паке, у которого почти 4 тыс. звёзд на GitHub.

Как мне кажется, этих причин достаточно, чтобы внедрить эту практику именования в свою команду. Она будет понятна всем, а кому-то уже знакомой, и не нужно изобретать "велосипед".

Тестирование в разном контексте

Рассматривая, как подобное реализовано в RSpec, мы приводили вот такой пример:

describe Store do
  describe '.sum' do
     context 'when summing positive numbers' do
        it { expect(described_class.sum(2, 2)).to eq 4 }
     end

     context 'when summing negative numbers' do
        it { expect(described_class.sum(-2, -2)).to eq -4 }
     end
  end
end

Знатоки, вероятно, обратили внимание, что тех же результатов в Go можно добиться через табличное тестирование.

То есть вот так:

func TestSum(t *testing.T) {
	tCases := []struct{
		x int
		y int
		res int
	}{
	    {2, 2, 4},
    	{3, 2, 5},
	}
	for _, tCase := range tCases {
		res := Sum(tCase.x,  tCase.y)
		...
	}
}

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

Документация даёт нам решение и предлагает использовать Subtests (подтесты).

func TestSum(t *testing.T) {
	tCases := []struct{...}{...}
	for _, tCase := range tCases {
		t.Run(
			fmt.Sprintf(“x=%d y=%d”, tCase.x, tCase.y),
 			func(t *testing.T) {
				// test body
			},
		)
	}
}

Вот теперь мы будем понимать, какой тестовый набор провалился.

Вы скажете, что данная ситуация хорошо ложится на концепцию табличного тестирования, но есть и другие. И будете абсолютно правы.

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

Что делать, есть нам нужно проверить работу экшена контроллера для авторизованного и не авторизованного пользователя?

Напрямую документация не даёт ответа на этот вопрос, но почему бы нам не воспользоваться функциональностью Subtests? Не вижу причин отказывать себе в этом. Единственное, чего не хватает, это некоего соглашения о том, как описывать в подтестах различные ситуации. Нам не известно такое соглашение в Go, не беда, возьмём соглашение из Better Specs.

func TestDeleteDatabase(t *testing.T) {
    t.Run("When the user is logged in", func(t *testing.T) {
	    // test body
	})
	
    t.Run("When the user is not logged in", func(t *testing.T) {
	    // test body
	})
}

Мне кажется, что выглядит хорошо, не так ли?

Итог

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

Проблему тестирования в разном контексте прекрасно можно решить при помощи Subtests, нужно лишь добавить к ним хорошее соглашение по описанию тестируемых случаев, как это сделано в Better Specs.

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

К сожалению, в Go соглашения - это слабая сторона, во всяком случае в сравнении с экосистемой Ruby. Я полагаю, что локально команды решают этот вопрос, но не хватает именно общепринятых соглашений.

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

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

Всем сил, терпения и чистого кода 💪😎