>
>
Всем привет. В этой статье я буду покрывать тестами простое RESTfull API приложение на Rails 5, которое я создавал в предыдущей статье.
Не мудрствуя лукаво, я буду использовать встроенный в Rails инструмент тестирования - библиотеку Minitest.
Почитать про нее можно тут.
Введение.
Я буду закрывать тестами уже написанный функционал.
Разработкой через тестирование займусь в другой статье.
Сделать так я решил по нескольким причинам:
- во-первых, тонны статей на эту тематику подают материал в виде фарша: разработка-тест-разработка-проверка-тест и тэдэ. Мне было тяжело продираться в мир рельсов сразу через TDD когда толком не понимаешь где тут разработка а где тесты, почему тесты такие, почему разработка такая, где особенности рельсов, а где особенности гема тестирования, где тут принципы разработки на рельсе, а где тут соблюдается принцип TDD.
- во-вторых, по моему опыту и мнению правильные вещи, такие как разработка через тестирование должны подаваться по принципу"мухи отдельно, котлеты отдельно". Отдельно понять разработку, отдельно понять тестирование и только после этого переходить к TDD в полный рост.
- ну и в-третьих, это моя записная книжка, как хочу, так и пишу ))).
Minitest.
Minitest - это встроенная в Rails библиотека тестирования всего приложения.
С ее помощью можно делать юнит тестирование, функциональное тестирование, системное тестирование. Последний вид тестирования мне не пригодится, поскольку в моем приложении нет представлений (view). Ограничусь только юнит-тестированием моделей и функциональным тестированием контроллеров и маршрутов.
Тестовое окружение.
Итак, для автоматизированного тестирования мне понадобится тестовая база данных и тестовые же наборы данных для нее.
Тестовые наборы хранятся в папке app/test/fixtures. Я заполню в ней два файла по одному для каждой модели и заполню их.
>
one: name: Cat1 two: name: Cat2
one: name: Prod1 price: 1.1 category: one two: name: Prod2 price: 1.2 category: one three: name: Prod3 price: 1.3 category: one four: name: Prod4 price: 2.5 category: two five: name: Prod5 price: 2.6 category: two
>
Далее выполню в терминале команду
rake db:test:prepare
В результате будет создана (если еще не была) тестовая база данных test.sqlite3 в каталоге app/db, которая заполнится данными из фикстур. Теперь можно делать тесты.
Тестирование моделей.
Как я уже упоминал выше: тестируя модели, я буду выполнять так называемое юнит-тестирование, проверять работоспособность кода в одном конкретном файле. Первой будет модель Category. Проверять
- работу валидации (запись не валидных данных, запись валидных данных
- удаление зависимых данных (при удалении категории должны быть удалены и связанные с ней продукты).
app/test/models/category_test.rb
require 'test_helper' class CategoryTest < ActiveSupport::TestCase test "category should not save without name" do category = Category.new assert_not category.save, 'saved category without name' end test "category should save with name" do category = Category.new category.name = 'Cat11' assert category.save, 'Did NOTsaved category with name' end test 'deleting category should delete its products' do category = Category.create(name:"Cat12") product = category.products.create({name:"product1",price:10}) product_id = product.id category.destroy assert_raises (ActiveRecord::RecordNotFound) {Product.find(product_id)} end end
Несколько слов про синтаксис. Класс CategoryTest наследует от ActiveSupport::TestCase, который наследует от Minitest::Test. В результате стали доступны методы проверки утверждений assert. Метод test 'описание теста' создает процедуру test_описание_теста, которая потом и запускается. Каждый тест я мог бы написать и так
def test_category_should_not_save_without_name do # тут пишу действия теста end
По сути, каждый test '' - это один атомарный шаг сценария. Шаги могут объединяться в связанные сценарии для проверки какого-то сценария действий пользователя. А сценарии могут объединяться в одном feature файле (например, category_test.rb), который проверяет конкретную область (функциональность) приложения.
Суть таких объединений, на мой взгляд, это широта изолированного контекста.
При создании сценария из нескольких шагов, контекст будет общим для этих шагов и запускать можно как весь сценарий и тогда шаги будут выполняться последовательно, так и каждый шаг отдельно, но тут есть вероятность что какому-то шагу потребуется результат работы предыдущего шага.
При создании фича файла с несколькими сценариями контекст можно, конечно, сделать общим между сценариями, но это скорее всего "что-то пошло не так" в планировании тестов. Общий тут смысловой контекст для тестировщика, например, тестируем работу корзины и создаем несколько сценариев в одном файле cart_test.rb. И когда что-то сломалось в работе корзины, нужно будет искать упавший тест только в этом файле и с ним работать.
Для тестирования моделей достаточно будет объединить несколько атомарных test в одном файле для каждой модели.
В первом тесте я проверяю валидацию, пытаюсь записать новую категорию без указания обязательных полей и жду, что запись не удастся. В этом мне поможет метод проверки утверждений assert_not.
Во втором тесте я проверяю, что новая категория запишется (метод assert) с указанием обязательных полей. Казалось бы, это лишний тест. Однако, если в будущем я расширю список обязательных полей, то этот тест упадет и напомнит мне покрыть тестами новые ограничения.
В третьем тесте я проверяю параметр dependent: :destroy в модели. При удалении категории все ее продукты также должны быть удалены. Есть несколько вариантов проверить что продукт был удален, один из них это словить исключение при попытке найти в базе несуществующую запись (assert_raises).
Описание всех доступных методов проверки утверждений, которые предоставляет Minitest, можно тут или тут.
Тестируем дальше.
app/test/models/product_test.rb
require 'test_helper' class ProductTest < ActiveSupport::TestCase test "Product chould not be saved without name and price" do category = categories(:one) product = category.products.new assert_not product.save, "saved product wothout name" product.name = "prod1" assert_not product.save, "saved product without price" end test "Product chould not be saved with price equal zero" do category = categories(:one) product = category.products.new product.name = "prod1" product.price = 0 assert_not product.save, "saved product with zero price" end test "Product chould be saved with name and price greater than zero" do category = categories(:one) product = category.products.new product.name = "prod1" product.price = 1.1 assert product.save, "Did NOTsaved product with name and price" end end
Тут мне понадобятся те самые фикстуры, которые я делал чуть выше.
Строка require 'test_helper'
загружает файл test_helper.rb, который все фикстуры в виде коллекций помещает в переменные по имени файла фикстуры. Так, например, для доступа к фикстурам категорий, я использую коллекцию categories, в которой по имени конкретного элемента получаю категорию category = categories(:one)
.
В этих тестах я так же проверяю валидацию.
>
Тестирование контроллеров.
Это тестирование будет уже функциональным, поскольку каждый тест будет проверять сразу несколько аспектов приложения:
- маршруты (routing), удалось ли по маршруту дойти до действия контроллера
- возвращаемые контроллером ответы и статусы
Первым будет контроллер categories_controller. Для него сгенерирую categories_controller_test.rb.
В терминале набираю
rails g test_unit:scaffold categories
и
rails g test_unit:scaffold products
В результате получаю файл с почти готовыми набросками тестов для каждого действия (action) контроллера. Мне осталось их чуть чуть дописать для моего случая.
>
require 'test_helper' class CategoriesControllerTest < ActionDispatch::IntegrationTest include AssertJson setup do @category = categories(:one) @category2 = categories(:two) end test "should get index" do get categories_url, as: :json assert_response :success assert_equal "index", @controller.action_name assert_json (@response.body) do has_only item 0 do has :id, @category2.id has :name, @category2.name has :products_count, @category2.products.count end item 1 do has :id, @category.id has :name, @category.name has :products_count, @category.products.count end end end test "should create category" do assert_difference('Category.count') do post categories_url, params: { category: { name: "Candy" } }, as: :json end assert_response 201 assert_equal "create", @controller.action_name assert_json(@response.body) do has_only item 0 do has :id has :name, "Candy" has :products_count end end end test "should NOT create category" do assert_no_difference('Category.count') do post categories_url, params: { category: { name: "" } }, as: :json end assert_response 422 assert_equal "create", @controller.action_name assert_json(@response.body) do has_only has :errors do has :name end end end test "should show category" do get category_url(@category), as: :json assert_response :success assert_equal "show", @controller.action_name end test "should update category" do patch category_url(@category), params: { category: { name: "Cat3" } }, as: :json assert_response 200 assert_equal "update", @controller.action_name end test "should destroy category" do assert_difference('Category.count', -1) do delete category_url(@category), as: :json end assert_response 204 assert_equal "destroy", @controller.action_name end end
require 'test_helper' class ProductsControllerTest < ActionDispatch::IntegrationTest include AssertJson setup do @product = products(:one) @product4 = products(:four) @product5 = products(:five) @category2 = categories(:two) end test "should get index" do get category_products_url(@category2), as: :json assert_response :success assert_equal "index", @controller.action_name assert_json (@response.body) do has_only item 0 do has :id, @product4.id has :name, @product4.name has :price, @product4.price end item 1 do has :id, @product5.id has :name, @product5.name has :price, @product5.price end end end test "should create product" do assert_difference('Product.count') do post category_products_url(@category2), params: { product: { name: "Product22", price: 22 } }, as: :json end assert_response 201 assert_equal "create", @controller.action_name assert_json (@response.body) do has_only item 0 do has :id has :name, "Product22" has :price, 22 end end end test "should NOT create product" do assert_no_difference('Product.count') do post category_products_url(@category2), params: { product: { name: "Product23", price: 0 } }, as: :json end assert_response 422 assert_equal "create", @controller.action_name assert_json (@response.body) do has_only has :errors do has :price end end end test "should show product" do get category_product_url(@category2,@product4), as: :json assert_response :success assert_equal "show", @controller.action_name end test "should update product" do patch category_product_url(@category2,@product4), params: { product: { name: "Product33", price: 33 } }, as: :json assert_response 200 assert_equal "update", @controller.action_name end test "should destroy product" do assert_difference('Product.count', -1) do delete category_product_url(@category2, @product4), as: :json end assert_response 204 assert_equal "destroy", @controller.action_name end end
>
Что тут добавилось нового?
Во-первых, это класс, от которого наследуется мой класс class CategoriesControllerTest < ActionDispatch::IntegrationTest
.
ActionDispatch::IntegrationTest
создан для интеграционного тестирования нескольких частей приложения (как не трудно догадаться) - маршрутизации и контроллеров. Это дает возможность имитировать запросы из вне, оставаясь в своей "песочнице".
Я говорю про методы get, post, delete и т.д, а также методы проверки утверждений assert_difference и assert_response.
Также в классе стали доступны переменные @controller, @request и @response
Во-вторых, появился блок setup do ... end
. Этот блок выполняется каждый раз перед запуском очередного test, устанавливая таким образом одинаковый контекст, чтобы не писать поиск категории в каждом тесте.
В-третьих, в каждом тесте проверяется возвращенный статус assert_response.
В-четвертых, в каждом тесте проверяется правильно ли был вызван метод (action) контроллера assert_equal "index", @controller.action_name
В-пятых, для проверки json, который возвращает контроллер я использовал гем gem "assert_json"
, который дает возможность красиво проверять строки json на наличие в них нужных ключей, их значений и отсутствия чего-то лишнего.
Чтобы мне стали доступны возможности этого гема, в начале определения класса я пишу include AssertJson
.
Теперь мне доступен метод assert_json(), has_only() для проверки отсутствия лишних ключей, item() для отбора конкретного элемента массива, has() для проверки наличия ключа и его значения.
Более подробно про этот гем смотреть тут.
>
Запуск тестов.
Запуск тестов нужно выполнять после каждого готового test, после готовности файла _test.rb и вообще запускать их нужно как можно чаще. А затем, если приложение вам дорого, запуск тестов нужно поместить в среду Continius Integration, где они будут запускаться после каждого коммита в репозиторий или по расписанию. Об этом с другой статье.
Запускать тесты командой
rals test
Заключение.
На этом пожалуй все.