RSpec bietet unglaublich viele Features, mit denen man seine Tests
zusammenstellen kann. let
, let!
, subject
, described_class
, eq
, eql
, equal
, etc etc –
Alles eigentlich im Dienste von übersichtlichen, lesbaren Tests.
Oft stützt man sich aber zu sehr auf diese Features und verliert dabei den Inhalt der Tests – den
Grund, warum man sie überhaupt schreibt – aus den Augen. Sobald man einige RSpec-Features benutzt
hat, sieht der Test bereits „fertig“ aus und bekommt nicht mehr den Feinschliff, den er vielleicht
benötigen würde.
Hier als Beispiel (wie in jedem Artikel über Unit Testing) ein Test einer einfachen add
Methode:
RSpec.describe Calculator do
describe '#add' do
let(:a) { 1 }
let(:b) { 2 }
subject { described_class.add(a, b) }
it 'does the thing' do
expect(subject).to be(3)
end
end
end
Aber: Ein Test sollte nicht nur sicherstellen, ob eine Methode das richtige tut, sondern auch als
Beispiel-Sammlung dienen, wie man mit der Methode arbeitet. Darum sollte der Code so realistisch
aufgerufen werden wie möglich. Aber wenn man die gleichen Methoden jenseits von Tests – im echten
Code – aufruft, benutzt man kein let
und kein subject
: Man benutzt Variablen.
Warum benutzen wir Variablen dann nicht auch in unseren Tests? Zum Beispiel so:
it 'does the thing' do
a = 1
b = 2
result = Calculator.add(a, b)
expect(result).to be(3)
end
Schon beim ersten Blick ist ersichtlich, was hier passiert. Der Test ist gut lesbar, weil er
aussieht wie echter Code, und nicht wie ein RSpec-Test.
Damit das funktioniert, ist es aber nicht nur wichtig, einen realistischen Testaufbau zu haben,
sondern auch, eine verständliche Expectation zu formulieren.
Schauen wir uns dieses (schlechte) Beispiel an:
RSpec.describe Book do
describe 'group_by_authors' do
it 'returns authors and their books' do
author = FactoryBot.create(:author)
book = FactoryBot.create(:book)
result = Book.group_by_authors
expect(result["Author 1"].first.name).to eq("Book 1")
end
end
end
Die Expectation ist mir unverständlich (und ich habe das Beispiel geschrieben). Es ist nicht sofort
ersichtlich, was in expect
landet, oder wo die Werte „Author 1“ und „Book 1“ herkommen. (Spoiler:
Aus den Factories. Bitte, wenn ihr etwas expected, dann schreibt es explizit in den Test, und nicht
implizit in die Factory).
Man kann natürlich versuchen, sich den Inhalt der Expectation herzuleiten: „Mmh … es gibt wohl
Properties … und darin irgendwas, das .first
unterstützt (vielleicht ein Array?), und das Element
hat dann eine .name
Property …“ aber es ist immer möglich, etwas zu übersehen.
Besser ist es, Expectations so vollständig wie möglich zu formulieren:
RSpec.describe Book do
describe 'group_by_authors' do
it 'returns authors and their books' do
author = FactoryBot.create(:author, name: "J. K. Rowling")
book = FactoryBot.create(:book, name: "Harry Potter", author: author)
result = Book.group_by_authors
expect(result).to eq({
"J. K. Rowling": [book]
})
end
end
end
Bei dieser Expectation wird direkt klar, welche Form das Resultat hat: Ein Hash, der Autorennamen
auf Arrays von Book
s mappt. Dadurch, dass [book]
verwendet wird, wissen wir, dass die Methode
kein neues Objekt pro Buch erstellt, sondern lediglich die vorhandenen Daten organisiert.
Anhand dieser Expectation kann man sich gut vorstellen, wie die Methode implementiert ist. Das
hilft, den vorhandenen Code zu verstehen, kann aber bereits beim Schreiben nützlich sein: Wenn schon
vorher klar ist, was von den Methoden erwartet wird, müssen sie häufig nur noch „runtergeschrieben“
werden. Dieser Ansatz funktioniert in unserer Erfahrung besonders gut, wenn es um Methoden geht,
deren In- und Outputs klar definiert werden können (Das, was die coolen Kids „Pure Functions“
nennen). Es lohnt sich also, Methoden zu schreiben, die auf Aktionen auf Values ausführen, statt zu
sehr auf Side Effects zu setzen.
Doch nicht nur die Expectations können verständlich gestaltet werden: Auch beim Test-Setup hat man
viele Möglichkeiten, komplexen Code zu benutzen.
Je weniger if
s, Loops, Mutations, before/after
-Hooks, TimeCops, VCRs, Fixtures und
verschachtelte Factories es gibt, umso einfacher kann ein Test von Oben nach Unten durchgelesen
werden. Das ist, worauf Tests optimiert werden sollten: Ganz normales Lesen™ von Oben nach Unten.
Hier ein Test, den wir bis vor kurzem so ähnlich in unserer Codebase hatten:
RSpec.describe MessageGroup do
describe 'group_hourly' do
it 'returns messages grouped by hours' do
@time_ago = DateTime.current.beginning_of_hour
10.times do
@time_ago -= 15.minutes
FactoryBot.create(:message, sent_at: @time_ago)
end
grouped_messages = group_hourly(
@time_ago,
DateTime.current
)
expect(grouped_messages.groups.length).to eq(3)
end
end
end
Abgesehen von der ungenauen Expectation (Die Länge von irgendwas ist 3? Na gut!) ist das ganze Setup
hinter einem 10.times
-Loop und einer @times_ago
-Variable versteckt. Wegen eines Edge Cases mit
Timezones mussten wir uns nach mehreren Wochen Pause wieder in diesen Test hineindenken und dabei
lange überlegen, was es bedeutet, wenn man von der beginning_of_hour
zehnmal 15 Minuten abzieht
und das dann nach Stunden gruppiert.
Hier zeigt sich die größte Schwäche von Tests mit zu schlauem Setup: Wenn man später nicht mehr
versteht, welche Parameter in eine Methode reingegeben werden, dann kann man auch nicht
nachvollziehen, was die Expectation prüft.
Nachdem wir den Timezone Bug gefunden haben, haben wir auch direkt den Test vereinfacht, um es beim
nächsten Mal einfacher zu haben:
RSpec.describe MessageGroup do
describe 'group_hourly' do
it 'returns messages grouped by hours' do
message_1 = FactoryBot.create(:message, sent_at: DateTime.parse("2019-01-01 00:59:59"))
message_2 = FactoryBot.create(:message, sent_at: DateTime.parse("2019-01-01 01:00:00"))
message_3 = FactoryBot.create(:message, sent_at: DateTime.parse("2019-01-01 01:59:59"))
message_4 = FactoryBot.create(:message, sent_at: DateTime.parse("2019-01-01 02:00:00"))
grouped_messages = MessageGroup.new(
DateTime.new("2019-01-01 00:00"),
DateTime.new("2019-01-01 03:00"),
'hour'
)
expect(grouped_messages.groups).to eq([
[message_1],
[message_2, message_3],
[message_4]
])
end
end
end
Wow! Hier ist klar: Wir erstellen vier Messages mit hardcodierten Zeiten, wir gruppieren sie nach
Stunden, und die Expectation ist auch klarer: grouped_messages.groups
liefert ein Array von
Arrays, und wir können direkt prüfen, ob unsere Messages korrekt darin gelandet sind.
Der neue Test ist zwar ein paar Zeilen länger als der alte, aber dafür viel schneller zu lesen. Wenn
Code wirklich öfter gelesen als geschrieben wird, dann sollte das ein guter Tausch sein!
Jedenfalls
Wie bei jedem Programmierthema gilt auch hier: Alles ist ein Trade Off. Bei jeder Methode von
RSpec hat sich jemand etwas gedacht. Manchmal hat Code einfach Side Effects (sonst könnte man
Programmieren auch direkt sein lassen) die getestet werden müssen. Manchmal hat man einfach keine
Zeit und ein schnell geschriebener Test ist besser als gar keiner.
Aber wenn man immer mal wieder den eigenen Blick schärft und bei neuen Tests kurz nachdenkt, kann
sich ganz neues Vertrauen in die eigene Test Suite entwickeln. Denn: Eine Test Suite, die einem
nicht ständig wegen Timezones um die Ohren fliegt, ist eine gute Test Suite.