Elixir. Пиши тести як бог
Тести — це те слово, про яке так багато говорять, але так мало роблять. Хотілося б висвітлити цю тему ширше. У цій статті я хотів би розібрати кілька простих ідіом, після яких рівень та якість ваших тестів зросте.
Думаю, що кожен розробник проходив такі стадії:
1. Заперечення.
«Та навіщо мені взагалі потрібні ваші тести». Справді, бувають такі ситуації, коли простіше понатискати кнопки та перевірити функціонал «в бою». Можливо, деякі кейси будуть втрачені, але завдання буде виконано.
2. Розпач
«Ну не виходить у мене писати тести, та й без них нормально». Така думка притаманна розробникам, які трохи застрягли і не можуть зрушити з мертвої точки.
3. Прийняття
«Ах, ось воно що». У цей момент ми дійсно розуміємо, що без тестів нікуди, і необхідно вкладати сили в те, щоб тести були якісно написані та легко підтримувані.
У результаті, ми підходимо до того питання, як домогтися «якості». Якість не має єдиного підходу, згідно з яким у вас вийде все ідеально і можна буде показати сусідові і сказати: «Дивись як у мене вийшло». Але ми можемо виділити основні кроки, звернувши увагу на які, вам вдасться досягти успіху в такій справі як тестування коду.
Розглянемо далі наступні кроки, деякі з них стилістичного характеру.
describe та test
Однією з фундаментальних речей є зрозуміти і коротко пояснити, що відбувається в конкретному тесті.
describe макрос описує випадок, функцію, дію і об’єднує тести за однаковою ознакою. У 99% випадків опис зводиться до того, яку функцію ми тестуємо та її арність.
# bad examples describe "hello" do ... end describe "My new function hello/2" do ... end # good examples describe "hello/2" do ... end describe "GET /users/:id" do ... end describe "when user is active" do ... end
Секція з поганими прикладами демонструє опис, який або не до кінця розкриває предмет тестування, або навантажує його непотрібними деталями. У той же час секція з хорошими прикладами дає чітко зрозуміти, що тестується, і не вводить в оману розробника.
Останній приклад — це додаткова опція того, як можна використовувати describe. У наведеному випадку він вказує на конкретний контекст, це буде корисно у тестуванні модулів з однією точкою входу (публічною функцією).
test макрос описує безпосередньо предмет тестування. Саме в цьому випадку починаються складності та проявляється потенціал «письменника».
# bad examples
describe "create_user/2" do
test "should have a success result" do
...
end
test "with invalid name" do
...
end
end
# good examples
describe "create_user/2" do
test "returns {:ok, %User{}} with valid params" do
...
end
test "returns {:error, %Ecto.Changeset{}} when user name is invalid" do
...
end
end
describe "GET /users/:id" do
test "returns user, 200 status with valid params" do
...
end
test "returns error, 404 status when user doesn't exist" do
...
end
end
# describe as a context
describe "when user is active" do
test "returns {:ok, %User{}}" do
...
end
end
describe "when user is inactive" do
test "returns {:error, :user_not_found}" do
...
end
endДумаю, ви вже помітили, що кожен опис починається з returns... і в цьому слові приховано все рішення. Ми завжди щось повертаємо, чи це буде атом :ok, {:error, _} або щось екзотичніше {:ok, [%User{}, ...]}. Такий опис допоможе програмісту швидше зорієнтуватися і без вникання у нутрощі зрозуміти, що ж тут відбувається.
setup_all и setup
Цей функціонал дозволяє організувати різні дії перед тим, як запустити самі тести. Це може бути створення необхідних даних, запуск процесів або робота з внутрішньою структурою папок та файлів.
setup [:clean_up_tmp_directory] def clean_up_tmp_directory(_context) do ... :ok end setup_all do [conn: Plug.Conn.build_conn()] end setup_all do MemoryCache.start_link end
Важливим моментом буде підкреслити те, що не варто йти в крайнощі і намагатися все запхати в setup. Також можна організувати красивий setup за допомогою приватних функцій, причому ми можемо заміксувати їх, наприклад setup [:base_setup, :user_setup]
Розмір тестових даних
Розмір створюваних тестових даних також є важливим пунктом. Я неодноразово зустрічав тести, у яких створювалися
# bad example
describe "list_users/1" do
setup do
application = insert(:application)
_users = insert_list(10, :user, application: application)
_homeless_users = insert_list(5, :user)
[application_id: application.id]
end
test "returns users filtered by application_id", %{application_id: application_id} do
users = Users.list_users(application_id)
assert length(users) == 10
end
end
# good example
describe "list_users/1" do
setup do
application = insert(:application)
user = insert(:user, application: application)
_homeless_user = insert(:user)
[application_id: application.id, user_id: user.id]
end
test "returns users filtered by application_id", %{application_id: application_id, user_id: user_id} do
assert [%User{id: ^user_id}] = Users.list_users(application_id)
end
endПриклад досить простий, але явно демонструє, що достатньо пари користувачів з різними звʼязками, щоб перевірити правильність роботи фільтра.
Factories
Фабрики — допоміжні модулі, які дозволяють наповнювати базу тестовими даними без будь-яких проблем.
Прозоре створення об’єктів
У фабриці можна описувати всі залежності, які необхідно створити. Робиться це для того, щоб задовольнити вимоги щодо зовнішніх ключів, або щоб об’єкт, який ви створюєте, був більш заповненим. Але такі дії мають свої наслідки, ви не контролюєте процес створення запису в БД. Погляньмо на приклад.
# bad example application = build(:application) |> with_users() |> insert() user = hd(application.users) # good example application = insert(:application) user = insert(:user, application: application)
У першому випадку ми створюємо програму разом із користувачами всередині. Як результат, ми не контролюємо кількість створених записів і динамічно вибираємо користувача із ланцюга. З цього можуть випливати моменти, коли наші тести стануть плаваючими.
Другий випадок гарантує нам прозорість створення користувача без прихованого створення зв’язків усередині. Тим самим гарантує стійкість до плаваючих тестів.
Traits як опис спеціальних випадків
Trait — це функція, яка надає спеціальний статус тому чи іншому об’єкту. Робиться це для того, щоб не дублювати безліч фабрик з
# bad example
def admin_factory do
%User{
name: sequence(:name, &"User #{&1}"),
email: sequence(:email, &"user_#{&1}@gmail.com"),
role: :admin
}
end
def manager_factory do
%User{
name: sequence(:name, &"User #{&1}"),
email: sequence(:email, &"user_#{&1}@gmail.com"),
role: :manager
}
end
# good example
def user_factory do
%User{
name: sequence(:name, &"User #{&1}"),
email: sequence(:email, &"user_#{&1}@gmail.com")
}
end
def admin(user) do
%{user | role: :admin}
end
def manager(user) do
%{user | role: :manager}
end
# usage
build(:user)
|> admin()
|> insert()З прикладу ми бачимо, що ми розширюємо поведінку створення користувача і не навантажуємо наші фабрики надмірними кейсами, тим самим слідуємо підходу DRY.
Висновок
В якості завершення хотілося сказати, що не існує ідеального підходу, але застосування «найкращих практик» інших розробників, приведе вас до тієї «найкращої практики» особисто для вас та вашої команди. Тому не соромтеся експериментувати та виробляти ваш власний підхід та стиль.
14 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівЯкщо чесно, то в принципі саме використання ex_machina (судячи з коду це вона) треба відносити то bad example
Аргументи?
Ну перше, то те шо функціонал ex_machina відтворюється самотужки написанням кількох функцій (див. hexdocs.pm/ecto/test-factories.html). Друге, цей підхід це низькорівнева шняга, яка може створювати дуже і дуже багато зайвих записів через оголошені асоціації в фабриках. Це культивується тим, шо API бібліотеки таке, що функція яка оголошує фабрику має арність 0 в базових прикладах, а реалізацію з арністю 1 уникають писати, бо ж то коду більше. Недавно рефакторив такі на проекті, і швидкість тестів вдалося збільшити в2-3 рази. Далі, щоб створити певний стан системи, треба добре знати схему збереження даних в базі. Це ще працює якось на малих проектах, на великих то повна срака. Особливо коли нові люди працюють над системою. Дуже часто бачив ситуації, коли тест використовує стан, який в принципі не повинен існувати згідно з бізнес-логікою. Або колись він був ок, код, який мав приводити до цього стану, змінився і тепер він неможливий, але він існує в тесті, і тест проходить, бо це може бути неістотно для нього. Або ще, коли дані створюються одні, тест не мав би проходити згідно з встановленими асоціаціями, але він проходить тільки тому, що якісь інші дані було створено під капотом і власне вони задовольняють тест. А, і ще там реалізація sequence галіма, використовується єдиний глобальний Agent.
В цілому, згоден.
Підійму трохи тему. Я написав бібліотеку, яка пропонує альтернативний підхід до створення тестових даних (на противагу ex_machina). Ось тут деталі github.com/fuelen/seed_factory
Що я тільки що прочитав? Для чого це? Що таке Elixir? Клікбейтом займаємось?
Теги до статі, такі як #QA проставляє редакція DOU.
gprivate.com/60nx9
gprivate.com/60nx9
Та нє, то так не робиться. Назва статті надана так, що перше що спадає на думку, це якась штука, що повязана з тестуванням. Слово language — це в ви же від себе додали, там могло ще бути framework чи library.
Мені здається єдиний misinformation який виник, це тег QA, на превью в facebook, та в тегах статі. Тому всі хто працює QA, пришли взнати щось нового, а тут якийcь Elixir.
Дякую за фідбек, наступного разу буду уточнювати про що йде мова.
На мій погляд цільова аудиторія це еліксир розробники.
Йєп. Ви праві, тому і я і запитав про клікбейт, в назві статті лише 5 слів і одне з них — тести, що тригерить QA. Але назва дуже промовиста, тому не думаю, що її треба міняти. А от додати пару речень, що ситуація — про написання unit-тестів на мові програмування Elixir, точно б не завадило.
Тю, я навпаки прийшов за тегом «Elixir» і стаття пушка виявилась, за що автору велика дяка!