Vollständige Dependent-Einstellungen in Rails-Models testen

Ein selten, aber leider regelmäßig wiederkehrendes Problem sind Fremdschlüsselbeziehungen beim Löschen. In Rails muss auf Seite des Schlüsselziels definiert werden, ob ein Fremdschlüssel auf null gesetzt werden darf oder ob das ganze Model gelöscht werden muss. Vergisst man diese Konfiguration wirft die Datenbank beim Löschen einen Fehler. Um dies zu vermeiden möchte ich alle Fremdschlüssel, die auf eine Model-Tabelle zeigen, überprüfen, ob sie entsprechend konfiguriert sind. Hierfür verwende ich ein shared Example in Rspec.

Fremdschlüssel finden

Die Schlüssel für das beschriebene Model lassen sich in zumindest mit Postgres folgendermaßen auflisten:

def foreign_keys(to_table)
  ActiveRecord::Base.connection.tables.map do |table_name|
    ActiveRecord::Base.connection.foreign_keys(table_name).select{ |index| index.to_table == to_table }
  end.flatten
end

Konfigurierte Assoziationen finden

Die Assoziationen eines Models finden wir mit folgendem Ansatz. Wir ignorieren Assoziationen mit Scopes, da hier nicht ohne weiteres festgestellt werden kann, ob diese alle möglichen Verbindungen abdecken. Auch beschränken wir uns auf Has-many- und Has-one-Beziehungen, da diese zumindest aktuell die einzig relevanten sind. Sollte hier etwas fehlen ist dies auch nicht weiter tragisch, da der Test im Zweifel lieber zu viel als zu wenig bemängeln soll.

def safe_associations(target_class)
  associations =
    target_class.reflect_on_all_associations(:has_many) +
    target_class.reflect_on_all_associations(:has_one)
  associations.select{ |assoc| assoc.options[:dependent].in?(%i{destroy nullify}) && assoc.scope.nil? }
end

Shared Example für verdächtige Keys

Nun können die gültigen Konfigurationen von den Fremdschlüsseln abziehen. Nur wenn die Differenz leer ist, sind alle Schlüssel konfiguriert. Da wir bei Scopes etwas übervorsichtig sind, erlauben wir aber auch eine ignore-Option, um bestimmte Fremdschlüssel zu ignorieren:

shared_examples 'a dependent model' do  |options = {}|
  it 'has a dependent option for has_* associations' do
    foreign_keys = foreign_keys(described_class.table_name)
    associations = safe_associations(described_class)
    ignore = options[:ignore] || []

    suspicious_keys = 
      foreign_keys.map{ |key| "#{key.from_table}.#{key.column}" } -
      associations.map{ |assoc| "#{assoc.klass.table_name}.#{assoc.foreign_key}" } -
      ignore

    expect(suspicious_keys).to be_empty
  end

  # ...
end

Nun noch die Fehlermeldungen modifizieren und etwas Doku ergänzen und schon kann das Example in Model-Specs eingebunden werden:

describe BookWithChapters do
  it_behaves_like 'a dependent model'
end

Verwischte Font-Awesome-Icons in Phantom JS

Wir reichern unsere auf Capybara und Phantom JS basierenden Feature-Tests gerne mit Screenshots an. Hierbei kam es regelmäßig zu einem Problem mit Font Awesome, wodurch der automatische Abgleich fehl schlug. Bei einzelnen Specs tritt das Problem nicht auf, erst wenn mehrere Tests mit unterschiedlichen Seiten abgelichtet werden zeigte sich folgendes Phänomen:

font-awesome-phantomjs

Alle Icons, wie hier das Such-Icon rechts, wurden wie links zu sehen verzerrt. Den Lösungsansatz brachte dieser Eintrag auf Stackoverflow: Ist die Schriftart nativ auf dem System vorhanden, tritt die Verzerrung nicht mehr auf. Unter Ubuntu ist das schnell gemacht:

sudo apt-get install fonts-font-awesome
sudo fc-cache -f -v

Herausfinden, wo eine Methode definiert wurde

In manchen Situationen ist es alles andere als offensichtlich, welche Klasse oder welches Modul eine Methode bereitstellt. Wer implementiert die Get-Methode für Rspec-Controller-Tests? Gerade in solch zusammengewürfelten Umgebungen ist das nur schwer nachvollziehbar. Ruby hilft hierbei: Die Metamethode method gibt Auskunft, woher eine Methodendefinition stammt:

require "rails_helper"

RSpec.describe MyController do
  it do
    puts method(:get)
  end
end

# => #<Method: RSpec::ExampleGroups::MyController(ActionController::TestCase::Behavior)#get>

Das Beispiel zeigt nicht nur die Klasse, sondern auch das includete Modul, welches für die Methode verantwortlich ist. Dies hilft beim Googeln oder man verwendet Ri:

ri ActionController::TestCase::Behavior#get

 

Rails-Konfiguration in Engine auslagern

Für die meisten Rails-Projekte verwende ich die gleichen Gems mit ähnlichen Konfigurationen. Um den Projektstart und den Update-Prozess zu vereinheitlichen, möchte ich eine Engine, die diese Abhängigkeiten und Konfigurationen übernimmt.

Für den einfacheren Einstieg erzeuge ich innerhalb einer bestehenden Rails-App eine neue Engine:

rails plugin new m3 --full

Diese wird zu Testzwecken im Gemfile des Elternprojekts verlinkt:

gem 'm3', path: 'm3'

weiterlesen

Bundler überspringt Gems mit Todo-Beschreibung

Komisches Verhalten von Bundler, welches bei mir vorher nie aufkam:

$ bundle install
...
Source does not contain any versions of 'gem (>= 0) ruby'

Das Problem scheint hier tatsächlich zu sein, dass die Gem-Spezifikation noch Todos enthält:

Gem::Specification.new do |s|
 # ...
 s.summary = "TODO: Summary of Gem."
 s.description = "TODO: Description of Gem."
end

Entferne ich diese, so läuft die Installation durch.

Ironischerweise warnt Bundler sogar, dass das Gem Todos oder Fixmes in der Spezifikation enthält. Dass es das Verzeichnis deshalb nicht ordnungsgemäß ausliest erschließt sich aber nicht. Hier wäre eine Fehlermeldung statt der Warnung geeigneter. Möglicherweise ist es auch nur ein Bug, eine fehlende Beschreibung rechtfertigt kein Ignorieren eines Gems.

Match API output with YAML fixture

A nice and simple approach to test you JSON APIs:

# spec/features/api/user_spec.rb

require 'rails_helper'

RSpec.feature 'User', type: :feature do
  it 'lists users' do
    user = create(:user, :as_admin)
    visit api_user_path(user, format: :json)
    expect(page).to match_yaml_fixture('api/user')
  end
end

This checks if the page source matches the data given in the fixture file. To make it more readable we format the expected JSON as YAML:

# spec/fixtures/api/user.yaml

---
user:
- id: 1
  first_name: Max
  last_name: Power
  role:
    id: 1
    name: Admin

weiterlesen

Overwriting and Overriding with define_method

Recently we stumbled upon this inheritance issue, which seemed very weird at the first:

class A
  def talk
    'A'
  end
end

class B < A
  def self.define_talk
    define_method :talk do
      super() << 'B'
    end
  end
end

class C < B
  define_talk
 
  def talk
    super << 'C'
  end
end

> C.new.talk
 => "AC"

The talk addition from class B doesn’t appear, even though define_talk is triggered by Class C. C’s super call seems to ignore its direct parent B.

weiterlesen

ActiveRecord: Klasse einer Model-Instanz ändern

ActiveRecord erlaubt das direkte Ändern der Klasse einer Model-Instanz mittels #becomes. Dies kann zum Beispiel hilfreich sein, wenn man für einen Spezialfall weitere Funktionen oder Validierungen zu einem Model hinzufügen will:

> company = Company.last
> company.persisted?
 => true
> company.valid?
 => true 
> company = company.becomes(RestrictedCompany)
> company.is_a?(RestrictedCompany)
 => true 
> company.is_a?(Company)
 => true 
> company.persisted?
 => true
> company.valid?
 => false

Ähnliches ließe sich zwar auch mit bedingten Validierungen erreichen, würde aber ab einem gewissen Umfang schlicht nicht mehr skalieren.

Ein weiterer, oft gesehener Ansatz wäre RestrictedCompany.find(company.id). Dieser ist jedoch von der Datenbank abhängig und somit vermutlich weniger performant. Zudem benötigt er wirklich persistierte Instanzen. Dies ist vor allem nervig, wenn man in Tests gerne Stubbing nutzt.

Zum Speichern der Klassenänderung kann man #becomes! verwenden. Dies passt auch die STI-Vererbungsspalte (in aller Regel type) an.

Action-Mailer-Previews im Spec-Verzeichnis

Die Mailer-Previews in Rails 4 sind ein Segen. Wer Rspec statt der üblichen Testsuite verwendet kann das Verzeichnis von test in spec ändern, so dass alle Testdateien zusammen bleiben:

# config/application.rb

# ...
config.action_mailer.preview_path = "#{Rails.root}/spec/mailers/previews"

Nach einem Serverneustart sollten die Previews gefunden werden:

# spec/mailers/previews/user_mailer_preview.rb

class UserMailerPreview < ActionMailer::Preview
  def send_invitation
    UserMailer.send_invitation(Invitation.first!)
  end
end

Die Vorschau kann man dann unter den üblichen URLs betrachten, z.B.:

http://localhost:3000/rails/mailers/user_mailer/send_invitation