Тестирование RESTfull api приложения на RoR 5 c Minitest

Всем привет. В этой статье я буду покрывать тестами простое 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

Заключение.

На этом пожалуй все.

Posted in Новости, Ruby on Rails, Тестирование and tagged , .

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *