Есть ли способ заставить запись не уничтожаться при запуске функционального теста в RSpec? (Рельсы 6)

Для контекста у меня есть метод контроллера под названием delete_cars. Внутри метода я вызываю destroy_all для ActiveRecord::Collection из Cars. Под destroy_all я вызываю другой метод, get_car_nums_not_deleted_from_portal, который выглядит следующим образом:

def get_car_nums_not_deleted_from_portal(cars_to_be_deleted)
  reloaded_cars = cars_to_be_deleted.reload
  car_nums = reloaded_cars.car_numbers

  if reloaded_cars.any?
    puts "Something went wrong. The following cars were not deleted from the portal: #{car_nums.join(', ')}"
  end

  car_nums
end

Здесь я проверяю, не были ли удалены какие-либо автомобили во время транзакции destroy_all. Если они есть, я просто добавляю сообщение puts. Я также возвращаю ActiveRecord::Collection независимо от того, есть какие-либо записи или нет, поэтому следующий код может это обработать.

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

'Some selected cars have been successfully deleted from the portal, however, some have not. The '\
"following cars have not been deleted from the portal:\n\n#{some_car_numbers_go_here}"

Как я могу заставить только одну запись завершиться ошибкой, когда мой код выполняет destroy_all, БЕЗ добавления дополнительного кода в мою Car модель (в виде before_destroy или чего-то подобного)? Я пытался использовать шпиона, но проблема в том, что когда он создается, это не настоящая запись в БД, поэтому мой запрос:

cars_to_be_deleted = Car.where(id: params[:car_ids].split(',').collect { |id| id.to_i })

не включает его.

Для еще большего контекста, вот тестовый код:

context 'when at least one car is not deleted, but the rest are' do
  it "should display a message stating 'Some selected cars have been successfully...' and list out the cars that were not deleted" do
    expect(Car.count).to eq(100)
    visit bulk_edit_cars_path
    select(@location.name.upcase, from: 'Location')
    select(@track.name.upcase, from: 'Track')
    click_button("Search".upcase)

    find_field("cars_to_edit[#{Car.first.id}]").click
    find_field("cars_to_edit[#{Car.second.id}]").click
    find_field("cars_to_edit[#{Car.third.id}]").click
    click_button('Delete cars')

    cars_to_be_deleted = Car.where(id: Car.first(3).map(&:id)).ids
    click_button('Yes')

    expect(page).to have_text(
                      'Some selected cars have been successfully deleted from the portal, however, some have not. The '\
                      "following cars have not been deleted from the portal:\n\n#{@first_three_cars_car_numbers[0]}".upcase
                    )
    expect(Car.count).to eq(98)
    expect(Car.where(id: cars_to_be_deleted).length).to eq(1)
  end
end

Любая помощь в этом будет принята с благодарностью! Это становится довольно разочаровывающим, лол.

🤔 А знаете ли вы, что...
Одной из ключевых особенностей Rails является активная запись (Active Record) - ORM-система, которая упрощает взаимодействие с базой данных.


1
54
2

Ответы:

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

Допустим, у вас есть запись RaceResult, которая всегда должна ссылаться на допустимую Car, и у вас есть ограничение БД, обеспечивающее это (в Postgres: ON DELETE RESTRICT). Вы можете написать тест, который создает записи RaceResult для некоторых из ваших записей Car:

it 'Cars prevented from deletion are reported` do
  ...
  do_not_delete_cars = Car.where(id: Car.first(3).map(&:id)).ids
  do_not_delete_cars.each { |car| RaceResult.create(car: car, ...) }

  click_button('Yes')

  expect(page).to have_text(...
end

Другой вариант — использовать некоторые знания о том, как ваш контроллер взаимодействует с моделью:

  allow(Car).to receive(:destroy_list_of_cars).with(1,2,3).and_return(false) # or whatever your method would return

На самом деле это не запустит метод destroy_list_of_cars, поэтому все записи останутся в БД. Затем вы можете ожидать сообщения об ошибках для каждой из выбранных вами записей.

Или, поскольку destroy_all вызывает метод destroy каждой записи, вы можете имитировать этот метод:

allow_any_instance_of('Car').to receive(:destroy).and_return(false) # simulates a callback halting things

allow_any_instance_of однако делает тесты хрупкими.

Наконец, вы могли бы подумать о том, чтобы просто не предвидеть проблемы до того, как они возникнут (может быть, вам даже не нужна страница массового удаления, чтобы быть такой полезной?). Если ваши пользователи видят более общую ошибку, есть ли страница, которую они могли бы отфильтровать, чтобы проверить, что там еще может быть? (Здесь нужно учитывать множество факторов, это зависит от важности функции для бизнеса и того, какие вещи могут пойти не так, если данные несовместимы).


Решено

Одним из способов «сымитировать» удаление записи для теста может быть использование block версии .to receive для возврата значения falsy.

Аргументом для блока является экземпляр записи, которая будет :destroyed.

Поскольку у нас есть этот экземпляр, мы можем проверить произвольную запись на «не уничтоженность» и вернуть блок nil, что укажет на «сбой» из метода :destroy.

В этом примере мы проверяем запись первой Car записи в базе данных и возвращаем nil, если она есть. Если это не первая запись, мы используем метод :delete, чтобы не вызвать бесконечный цикл в тесте (тест будет продолжать вызывать макет :destroy).

allow_any_instance_of(Car).to receive(:destroy) { |car|
      # use car.delete to prevent infinite loop with the mocked :destroy method
      if car.id != Car.first.id
        car.delete
      end
      # this will return `nil`, which means failure from the :destroy method
}

Вы можете создать метод, который принимает список записей и решать, какую из них вы хотите :destroy для более точного тестирования!

Я уверен, что есть другие способы обойти это, но это лучшее, что мы нашли до сих пор :)