Disclaimer: Der obige Absatz könnte Spuren von Ironie enthalten. Der nächste womöglich auch.
Die Theorie hinter TDD, BDD und wie sie alle heißen, fand der Autor nie interessant genug, um sie in Gänze zu verinnerlichen oder gar anzuwenden. Ein paar Testfälle auszuformulieren für die Kernfunktionen einer Software schafft er meistens auch, für den Rest gibt es Nutzerfeedback. Jedenfalls so lange, bis Dimensionalität von Kombinationsmatrizen aus Zugriffsrechten, Rollen und Modellzuständen nicht ins Unermessliche wächst und im Durchschnitt eine User-zu-nichtfunktionierende-Zustandskombination von 1:1 herauskommt. Für diese nicht zu hoch gegriffenen Anforderungen gibt es… genau: Minitest[1], das einfachste aller Test-Frameworks. So einfach, dass sogar der Autor es versteht und so leichtgewichtig und schnell, dass es eigentlich keine Ausreden gibt.
Die Einsicht, dass jedes Prozent > 0% Testabdeckung besser ist als gar nichts, sorgte irgendwann zu dem Bestreben, mit minimalem (Wartungs-)Aufwand zu akzeptablen Coverage-Ergebnissen zu kommen.
Was gibt’s da so zu testen?
Im Folgenden werde ich ein Beispiel aus einem Open-Source-Projekt nehmen, um mir keine Fantasie-Beispiele ausdenken zu müssen. Der Kontext ist eine Rails-Anwendung zur Verwaltung von Amphibienzäunen[2]. Den Zaunstandorten sind Helfer, Verantwortliche und ein Admin zugewiesen. Der Admin kann festlegen, welche dieser Nutzer-Kategorien z.B. gewisse Daten sehen oder Bearbeiten darf. Die weiteren Details sind hier nicht näher relevant.
So sieht z.B. ein Integrationstest aus, der den Zugriff auf einen ‘Bearbeiten’-Link eines Standortes prüft, abhängig von der Bearbeitungs-„Policy” des Standorts:
require 'test_helper'
class SiteInvolvementTest < ActionDispatch::IntegrationTest
setup do
# dieser Code läuft vor jedem Test-Case
end
test 'can edit site as admin regardless of policy' do
sign_in users(:admin)
site = sites(:test)
visit site_url(site)
assert_link 'Standortbeschreibung'
assert_link 'Standort bearbeiten'
site.update_column :site_edit_policy, 'authenticated'
visit site_url(site)
assert_link 'Standort bearbeiten'
site.update_column :site_edit_policy, 'responsible'
visit site_url(site)
assert_link 'Standort bearbeiten'
site.update_column :site_edit_policy, 'admin'
visit site_url(site)
assert_link 'Standort bearbeiten'
end
test 'can edit site as involved user for involved policy or lower' do
site = sites(:involved)
sign_in users(:involved)
# den Rest kann man sich ja denken.
end
test 'can edit site as responsible user for responsible policy or lower' do
#...
end
end
Erklärung: es gibt also eine Basisklasse ActionDispatch::IntegrationTest
, von der eine konkrete Test-Klasse abgeleitet wird. Darin werden Testfälle definiert, auf die syntaktisch leichtgewichtigste Art, die man sich ausdenken kann: die Methode test
bekommt als einziges Argument eine Beschreibung des Szenarios und als Block-Argument wird die imperativ gehaltene Test-Prozedur beschrieben.
Mir gefällt dabei die einfach zu verstehende „Tu dies, dann tu das und erwarte jenes”-Semantik.
In diesem konkreten Fall wird schon klar, dass die Kombinatorik aus „Zugriffs-Policy” und „Nutzerrolle” schnell sehr umfangreich wird und Test-Code sich unverschämt oft wiederholt.
Ein möglicher Lösungsansatz wäre ein riesiger Test-Case, der über alle User-Rechte und/oder Policies iteriert und viele Assertions über die Kombinatorik enthält. Dann verliert man aber vor lauter Iterationslogik schnell die Übersicht über die eigentlichen Rolle/Policy-Kombinationen.
Ein anderer möglicher Lösungsansatz kommt aus der BDD-Welt (Behaviour Driven Development), wo die verschiedenen Frameworks auch verschachtelte Szenarien zulassen. RSpec[3] sei hier als bekanntestes Beispiel genannt:
RSpec.describe 'Editing a Site' do
describe 'as admin user' do
before :each do
sign_in users(:admin) # sign_in ist eine Methode aus Devise
end
let(:site) { sites :test }
it 'should be allowed' do
visit site_url(site)
expect(page).to have_link('Standortbeschreibung')
expect(page).to have_link('Standort bearbeiten')
# ...den Rest sparen wir uns
end
end
scenario 'as involved user' do
before :each do
sign_in users(:involved)
end
# ...den Rest kann man sich denken
end
# ...
end
Was mich schon immer gestört hat ist der syntaktische Ballast, der hier mitkommt. Ich will kein expect(page).to have_link('Standort bearbeiten')
, mir recht ein assert_link 'Standort bearbeiten'
. Außerdem will ich gar nicht wissen, was für ein Haufen Magie hinter let
oder it
steckt (das ist nämlich mehr als man erwartet!) und wo denn plötzlich page
herkommt. Ich mag es explizit oder implizit, aber bitte nicht zu bunt gemischt.
Ich will gruppieren!
Was ich mir also wünsche, ist etwas, wo ich
- nach Rolle/Policy gruppieren um Redundanz im Setup-Code zu vermeiden
- Setup-Code aus den Test-Cases raus halten,
- eine explizite Formulierung meiner Erwartungen aus User-Sicht im Namen des Test-Cases formulieren kann.
Zum Beispiel so etwas:
require 'test_helper'
class SiteInvolvementTest < NestedScenarioTest
describe 'as a public user' do
setup do
sign_in users(:public)
@site = sites(:test)
end
describe 'on a responsible site' do
setup do
@site.update_column :site_edit_policy, 'public'
end
I 'should be able to edit' do
visit site_url(@site)
assert_link 'Standortbeschreibung'
assert_link 'Standort bearbeiten'
end
end
describe 'on a private site' do
setup do
@site.update_column :site_edit_policy, 'private'
end
I 'should not be able to edit' do
visit site_url(@site)
assert_link 'Standortbeschreibung'
refute_link 'Standort bearbeiten'
end
end
...
end
describe 'as an involved user' do
describe 'on a public site' do
...
end
describe 'on a private site' do
# ...
end
end
end
… also lass uns so etwas mal bauen. Was wir dabei im Hinterkopf behalten: setup
ist eine Klassenmethode, die eine Callback-Methode definiert, die vor jedem Test-Case aufgerufen wird. Es können beliebig viele Setup-Blöcke definiert werden, die in ihrer Definitionsreihenfolge aufgerufen werden. Eine Sammlung von Testszenarien bekommt man durch Erben von ActionDispatch::IntegrationTest
. Also können wir eine eigene Test-Basisklasse (in diesem Fall NestedScenarioTest
) definieren, von der all unsere Tests abgeleitet werden. Und es ergibt sich eine mögliche Implementierung von describe
wie folgt:
class NestedScenarioTest < ActionDispatch::IntegrationTest
def self.describe(what, &block)
# generiere einen Klassennamen aus der Szenario-Beschreibung
# TODO: Sonderzeichen berücksichtigen ;-)
klass_name = what.gsub(/\s+/, '_').camelize
# baue eine neue Klasse, die von self erbt...
klass = Class.new self
# ... und binde sie an den Namespace von self
const_set klass_name, klass
# was auch immer im Block steht:
# evaluiere es im Kontext der neuen Kinds-Klasse
klass.instance_eval &block
end
end
Das schenkt uns schonmal verschachtelte Szenarien, indem für jedes Szenario eine neue Kindsklasse gebildet wird, die alles (also auch Setup-Callbacks und Testfälle) von der Elternklasse erbt. Erste und zweite Anforderung: erfüllt ✅.
Die Methode I
ist im wesentlichen nur ein Alias für test
, um näher an der User-zentrierten Testbeschreibung zu bleiben:
class NestedScenarioTest
def self.I(scenario, &block)
test "I #{scenario}", &block
end
end
Dritte Anforderung: erfüllt ✅
Hat man dadurch jetzt so viel gewonnen?
Die generierten Test-Namen sehen vielleicht etwas umständlicher aus, sind aber immer noch gut lesbar. Nehmen wir folgendes Testbeispiel
class SiteInvolvementTest < NestedScenarioTest
describe 'as a public user' do
describe 'on a responsible site' do
I 'should be able to edit' do
# ...
end
end
end
end
Sollte diese Test fehlschlagen, so erscheint er folgendermaßen in der Konsolen-Ausgabe:
Error:
SiteInvolvementTest::AsAPublicUser::OnAResponsibleSite#test_I_should_be_able_to_edit
# ...
Weil es so schön einfach ist, definieren wir uns gleich noch mehr syntaktischen Zucker:
class NestedScenarioTest
def self.as(name, ...)
self.describe("as #{name}", ...)
end
end
für eine etwas kompaktere Test-Beschreibung:
class SiteInvolvementTest < NestedScenarioTest
as 'a nonexistent user' do
# ...
end
end
Und weil wir immer mit den gleichen Nutzern testen, die als Fixtures in der Datenbank liegen und nach guter Sitte einer Namenskonvenntion folgen, lassen sich auch gleich mehrere Testfälle zusammenfassen mit folgendem Code:
class NestedScenarioTest
def self.as_user(user_name, &block)
describe "as user #{user_name}" do
setup do
@current_user = users(user_name) # Hier könnte auch eine Factory aufgerufen werden oder sowas.
sign_in @current_user # ergibt nur Sinn, wenn an Devise o.Ä. nutzt.
end
module_eval &block
end
end
# Default für user_names ist hier die Liste unserer User Fixtures/Factories
def self.as_any_user(user_names = %w(user involved responsible admin), &block)
user_names.each do |user_name|
as_user user_name, &block
end
end
end
Somit schaut die eigentliche Test-Klasse recht übersichtlich aus:
class SiteInvolvementTest < NestedScenarioTest
describe 'a private site' do
setup do
@site = sites :test
@site.update_column :site_edit_policy, 'private'
visit @site
end
as_any_user %w(user involved responsible) do
I 'should not see an edit link' do
assert_link 'Standortbeschreibung'
refute_link 'Standort bearbeiten'
end
end
as_user 'admin' do
I 'should see an edit link' do
assert_link 'Standortbeschreibung'
assert_link 'Standort bearbeiten'
end
end
end
describe 'a public site' do
as_any_user do
I 'should see an edit link' do
assert_link 'Standort bearbeiten'
end
end
end
# usw.
end
Aufmerksamen Menschen mögen jetzt ein paar Ideen gekommen sein:
Wenn Szenarien voneinander erben, werden dann nicht Testfälle mehrfach ausgeführt?
Korrekt, deswegen sollte man in einem describe
-Block entweder nur weitere describe
-Blöcke oder nur test
-Blöcke verwenden. Sie zu mischen führt zwar zu keinem Fehler, aber eben zu redundanten Test-Aufrufen.
Kann ich damit nicht auch komplette Logiktabellen durchtesten?
Natürlich! Nicht nur zweidimensionale. Und dabei kriegt man im Falle fehlgeschlagener Assertions auch gleich die benannte Kombination der Parameter geschenkt, statt nur „irgendwo in dieser Iteration ist ein Fehler”. So also z.B.:
class SiteInvolvementTest < NestedScenarioTest
matrix = {
public: {public: true, involved: false, responsible: false, private: false},
involved: {public: true, involved: true, responsible: false, private: false},
resposible: {public: true, involved: true, responsible: true, private: false},
admin: {public: true, involved: true, responsible: true, private: true}
}
matrix.each do |user, edit_policies|
as_user user do
edit_policies.each do |edit_policy, expecting_success|
describe "on a #{edit_policy} site" do
setup do
@site = sites :test
@site.update_column site_edit_policy: edit_policy
visit @site
end
if expecting_success
I 'should see an edit link' do
assert_link 'Standort bearbeiten'
end
else
I 'should not see an edit link' do
refute_link 'Standort bearbeiten'
end
end
end
end
end
end
Warum nimmst du nicht einfach Minitest::Spec?
Weil Specs etwas anderes sind als Tests, ich der BDD-Philosophie nicht folge und die Semantik meiner verschachtelten Test-Gruppierungen selbst festlegen möchte, ohne ein weit verbreitetes Denk-Schema zu suggerieren.
Wenn Minitest so einfach und leicht zu erweitern ist, klappt das doch auch für andere Dinge als Nutzer und Rechte…
Genau. Und nun gehet hinaus und verkündet die frohe Botschaft, dass weniger mehr ist und nur Ruby* so unverschämt flexibel ist, dass man seine eigene passende Syntax bauen kann und sollte. *) Okay, und Perl natürlich. Aber wer nutzt das heute noch.
Wie heißt das Gem?
Die paar Code-Zeilen kann man auch eben selbst in die test_helper.rb
kopieren, das geht schneller als ein Gem zu installieren und konfigurieren. Hier ist der vollständige Code, je nach Fixture-/Factory-Setup und Auth-Library wird man Stellen anpassen müssen:
class ActiveSupport::TestCase
# Alle Test-Klassen erben hiervon.
# Wenn wir die ganze Logik hier definieren, steht sie allen Arten
# von Tests (Model-Tests, Controller-Tests) zur Verfügung
USER_NAMES = [] # Liste aller User-Namen, für die z.B. eine Factory existiert
def self.describe(what, &block)
klass_name = what.gsub(/\s+/, '_').camelize
if const_defined? klass_name
klass = const_get klass_name
else
klass = Class.new self
const_set klass_name, klass
end
klass.instance_eval &block
end
def self.as(name, ...)
self.describe("as #{name}", ...)
end
def self.I(scenario, &block)
test "I #{scenario}", &block
end
def self.it(...)
test "it #{scenario}", &block
end
def self.as_user(user_name, &block)
describe "as user #{user_name}" do
setup do
# die folgende Logik ist komplett projektabhängig
Current.user = @current_user = User.find_by email: "#{user_name}@test.example.com"
end
module_eval &block
end
end
def self.as_any_user(user_names = USER_NAMES, &block)
user_names.each do |user_name|
as_user user_name, &block
end
end
end
class ActionDispatch::IntegrationTest
# Diese Klasse erbt auch von ActiveSupport::TestCase, wir müssen
# hier also nur Integrationstest-Spezifika überschreiben
def sign_in(user)
super
# projektabhängige Logik:
Current.user = @current_user = user
end
def sign_out(user)
super
# projektabhängige Logik:
Current.user = @current_user = nil
end
def self.as_user(user_name, &block)
describe "as user #{user_name}" do
setup do
# projektabhängige Logik:
sign_in User.find_by(email: "#{user_name}@test.example.com")
end
module_eval &block
end
end
end
-
Dokumentation für Minitest: https://github.com/minitest/minitest ↩
-
für die Amphibien–Interessierten: https://amphis.de ↩
-
RSpec und geschachtelte Kontexte: https://rspec.info/features/3–12/rspec–core/example–groups/basic–structure/ ↩