Inleiding tot property based testen
Je kunt hier alle code van dit hoofdstuk vinden
Sommige bedrijven vragen je om de Romeinse cijferkata te doen als onderdeel van het sollicitatiegesprek. Dit hoofdstuk laat zien hoe je dit met TDD kunt aanpakken.
We gaan een functie schrijven die een Arabisch getal (de cijfers 0 tot en met 9) omzet naar een Romeins cijfer.
Als je nog nooit van Romeinse cijfers hebt gehoord: dit is de manier hoe de Romeinen vroeger getallen schreven.
Je bouwt ze door symbolen aan elkaar te plakken en die symbolen stellen getallen voor
Zo staat I voor "éen" en staat III voor drie.
Lijkt makkelijk, maar er zijn een paar interessante regels. V betekent vijf, maar IV is 4 (niet IIII).
MCMLXXXIV is 1984. Dat ziet er ingewikkeld uit en het is moeilijk voor te stellen hoe we code kunnen schrijven om dit vanaf het begin uit te zoeken.
Zoals dit boek benadrukt, is een belangrijke vaardigheid voor softwareontwikkelaars het identificeren van "dunne verticale segmenten" van nuttige functionaliteit en vervolgens te itereren naar resultaat. De TDD-workflow vergemakkelijkt iteratieve ontwikkeling.
Dus, in plaats van te starten met 1984, laten we beginnen met 1.
Schrijf eerst de test
func TestRomanNumerals(t *testing.T) {
got := ConvertToRoman(1)
want := "I"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}Als je tot hier in het boek bent gekomen, vind je het hopelijk erg saai en routineus. Dat is goed.
Probeer de test uit te voeren
./numeral_test.go:6:9: undefined: ConvertToRomanLaat de compiler je de weg wijzen.
Schrijf de minimale hoeveelheid code om de test te laten uitvoeren en de falende test output te controleren
Creëer onze functie, maar zorg ervoor dat de test nog niet slaagt. Zorg er altijd voor dat de tests falen zoals je verwacht.
func ConvertToRoman(arabic int) string {
return ""
}De test zou nu moeten werken
=== RUN TestRomanNumerals
--- FAIL: TestRomanNumerals (0.00s)
numeral_test.go:10: got '', want 'I'
FAILSchrijf genoeg code om de test te laten slagen
func ConvertToRoman(arabic int) string {
return "I"
}Refactor
Er is nog niet zoveel te refactoren op dit moment.
Ik weet dat het vreemd voelt om het resultaat gewoon hard te coderen, maar met TDD willen we zo lang mogelijk uit de "rode" situatie blijven. Het voelt misschien alsof we niet veel hebben bereikt, maar we hebben onze API gedefinieerd en een test uitgevoerd die een van onze regels vastlegt, ook al is de "echte" code behoorlijk dom.
Gebruik dat ongemakkelijke gevoel nu om een nieuwe test te schrijven die ons dwingt om iets minder domme code te schrijven.
Schrijf eerst je test
We kunnen subtests gebruiken om onze tests netjes te groeperen
func TestRomanNumerals(t *testing.T) {
t.Run("1 gets converted to I", func(t *testing.T) {
got := ConvertToRoman(1)
want := "I"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("2 gets converted to II", func(t *testing.T) {
got := ConvertToRoman(2)
want := "II"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
}Probeer de test uit te voeren
=== RUN TestRomanNumerals/2_gets_converted_to_II
--- FAIL: TestRomanNumerals/2_gets_converted_to_II (0.00s)
numeral_test.go:20: got 'I', want 'II'Niet heel veel verbazends hier
Schrijf genoeg code om de test te laten slagen
func ConvertToRoman(arabic int) string {
if arabic == 2 {
return "II"
}
return "I"
}Ja, het voelt nog steeds alsof we het probleem niet echt aanpakken. Dus moeten we meer tests schrijven om vooruit te komen.
Refactor
We hebben wat herhaling in onze tests. Wanneer je iets test waarvan je het gevoel hebt dat het een kwestie is van "gegeven input X, verwachten we Y", kun je beter tabelgebaseerde tests gebruiken.
func TestRomanNumerals(t *testing.T) {
cases := []struct {
Description string
Arabic int
Want string
}{
{"1 gets converted to I", 1, "I"},
{"2 gets converted to II", 2, "II"},
}
for _, test := range cases {
t.Run(test.Description, func(t *testing.T) {
got := ConvertToRoman(test.Arabic)
if got != test.Want {
t.Errorf("got %q, want %q", got, test.Want)
}
})
}
}We kunnen nu eenvoudig meer cases toevoegen zonder dat we nieuwe testboilerplates hoeven te schrijven.
Laten even doorzetten en verder gaan met 3
Schrijf eerst je test
Voeg het onderstaande toe aan je testcases:
{"3 gets converted to III", 3, "III"},Probeer de test uit te voeren
=== RUN TestRomanNumerals/3_gets_converted_to_III
--- FAIL: TestRomanNumerals/3_gets_converted_to_III (0.00s)
numeral_test.go:20: got 'I', want 'III'Schrijf genoeg code om de test te laten slagen
func ConvertToRoman(arabic int) string {
if arabic == 3 {
return "III"
}
if arabic == 2 {
return "II"
}
return "I"
}Refactor
Oké, ik begin deze if-statements niet meer zo leuk te vinden. En als je goed naar de code kijkt, zie je dat we een string van I's bouwen die gebaseerd is op de waarde van arabic.
We "weten" dat we voor ingewikkeldere getallen een soort rekenkunde en het samenvoegen van tekenreeksen zullen moeten toepassen.
Laten we met deze gedachten een refactoring proberen. Het is misschien niet geschikt voor de uiteindelijke oplossing, maar dat is oké. We kunnen onze code altijd weggooien en opnieuw beginnen met de tests die we als leidraad hebben.
func ConvertToRoman(arabic int) string {
var result strings.Builder
for i := 0; i < arabic; i++ {
result.WriteString("I")
}
return result.String()
}Je herinnert je misschien strings.Builder nog van onze discussie over benchmarking
Een Builder wordt gebruikt om efficiënt een string te bouwen met behulp van Write-methoden. Dit minimaliseert het kopiëren van geheugen.
Normaal gesproken zou ik pas met dit soort optimalisaties beginnen als ik daadwerkelijk een prestatieprobleem heb. Maar de hoeveelheid code is niet veel groter dan een "handmatige" toevoeging aan een string, dus we kunnen net zo goed de snellere aanpak gebruiken.
De code ziet er wat mij betreft beter uit en beschrijft het domein zoals wij dat nu kennen.
De Romeinen kende de DRY-principes ook...
Het wordt nu ingewikkelder. De Romeinen dachten in hun wijsheid dat herhalende tekens moeilijk te lezen en te tellen zouden worden. Een regel met Romeinse cijfers is dus dat je hetzelfde teken niet vaker dan drie keer achter elkaar mag herhalen.
In plaats daarvan neem je het op één na hoogste symbool en verminder je het vervolgens door er een symbool links van te plaatsen. Niet alle symbolen kunnen voor vermindering worden gebruikt; alleen I (1), X (10) en C (100).
Bijvoorbeeld, 5 in Romeinse cijfers is een V. Om 4 te maken, doe je niet IIII, maar IV.
Schrijf eerst je test
{"4 gets converted to IV (can't repeat more than 3 times)", 4, "IV"},Probeer de test uit te voeren
=== RUN TestRomanNumerals/4_gets_converted_to_IV_(cant_repeat_more_than_3_times)
--- FAIL: TestRomanNumerals/4_gets_converted_to_IV_(cant_repeat_more_than_3_times) (0.00s)
numeral_test.go:24: got 'IIII', want 'IV'Schrijf genoeg code om de test te laten slagen
func ConvertToRoman(arabic int) string {
if arabic == 4 {
return "IV"
}
var result strings.Builder
for i := 0; i < arabic; i++ {
result.WriteString("I")
}
return result.String()
}Refactor
Ik vind het niet prettig dat we het patroon van het opbouwen van strings hebben doorbroken, en ik zou graag op die manier willen doorgaan.
func ConvertToRoman(arabic int) string {
var result strings.Builder
for i := arabic; i > 0; i-- {
if i == 4 {
result.WriteString("IV")
break
}
result.WriteString("I")
}
return result.String()
}Om 4 te laten "passen" bij mijn huidige denkwijze, tel ik nu af vanaf het Arabische getal en voeg ik naarmate we vorderen symbolen toe aan de reeks. Ik weet niet zeker of dit op de lange termijn zal werken, maar we zullen zien!
Laten we zorgen dat 5 ook werkt
Schrijf eerst de test
{"5 gets converted to V", 5, "V"},Probeer de test uit te voeren
=== RUN TestRomanNumerals/5_gets_converted_to_V
--- FAIL: TestRomanNumerals/5_gets_converted_to_V (0.00s)
numeral_test.go:25: got 'IIV', want 'V'Schrijf genoeg code om de test te laten slagen
Kopieer gewoon de aanpak die we voor 4 hebben gebruikt
func ConvertToRoman(arabic int) string {
var result strings.Builder
for i := arabic; i > 0; i-- {
if i == 5 {
result.WriteString("V")
break
}
if i == 4 {
result.WriteString("IV")
break
}
result.WriteString("I")
}
return result.String()
}Refactor
Herhaling in lussen zoals deze is meestal een teken dat een abstractie wacht om benoemd te worden. Kortsluitende lussen kunnen een effectief hulpmiddel zijn voor leesbaarheid, maar ze kunnen je ook iets anders vertellen.
We gaan over ons Arabische getal heen en als we bepaalde symbolen tegenkomen roepen we break aan, maar wat we eigenlijk doen is op een onhandige i verlagen.
func ConvertToRoman(arabic int) string {
var result strings.Builder
for arabic > 0 {
switch {
case arabic > 4:
result.WriteString("V")
arabic -= 5
case arabic > 3:
result.WriteString("IV")
arabic -= 4
default:
result.WriteString("I")
arabic--
}
}
return result.String()
}Als ik goed kijk naar de code, en onze tests van een aantal zeer eenvoudige scenario's, kan ik zien dat ik, om een Romeins cijfer te maken, iets van
arabicmoet aftrekken terwijl ik symbolen toepas.De
for-lus is niet langer afhankelijk van eenien in plaats daarvan blijven we de string opbouwen totdat we genoeg waarden vanarabichebben afgetrokken.
Ik ben er vrij zeker van dat deze aanpak ook voor 6 (VI), 7 (VII) en 8 (VIII) zal gelden. Voeg de cases desalniettemin toe aan onze testsuite en controleer dit (ik zal de code niet opnemen om dit hoofdstuk niet te lang te maken. Kijk op GitHub voor voorbeelden als je het niet zeker weet hoe je dit doet).
9 volgt dezelfde regel als 4 in die zin dat we 1 moeten aftrekken van de representatie van het volgende getal. 10 wordt in Romeinse cijfers weergegeven met X; dus 9 zou IX moeten zijn.
Schrijf eerst je test
{"9 gets converted to IX", 9, "IX"},Probeer de test uit te voeren
=== RUN TestRomanNumerals/9_gets_converted_to_IX
--- FAIL: TestRomanNumerals/9_gets_converted_to_IX (0.00s)
numeral_test.go:29: got 'VIV', want 'IX'Schrijf genoeg code om de test te laten slagen
We zouden dezelfde aanpak moeten kunnen hanteren als voorheen
case arabic > 8:
result.WriteString("IX")
arabic -= 9Refactor
Het lijkt erop dat de code ons nog steeds vertelt dat er ergens een refactoring plaats moet vinden, maar voor mij is dat niet helemaal duidelijk waar, dus laten we verdergaan.
Ik sla de code hiervoor ook over, maar voeg aan je testcases een test voor 10 toe die X zou moeten zijn en zorg dat deze slaagt voordat je verder leest.
Hier zijn een paar tests die ik heb toegevoegd, omdat ik er vertrouwen in heb dat onze code tot 39 zou moeten werken
{"10 gets converted to X", 10, "X"},
{"14 gets converted to XIV", 14, "XIV"},
{"18 gets converted to XVIII", 18, "XVIII"},
{"20 gets converted to XX", 20, "XX"},
{"39 gets converted to XXXIX", 39, "XXXIX"},Als je ooit OO-programmering hebt gedaan, weet je dat je switch-statements met enige argwaan moet bekijken. Meestal leg je een concept of data vast in een imperatieve code, terwijl het in werkelijkheid in een klassenstructuur zou kunnen worden vastgelegd.
Go is strikt genomen niet OO, maar dat betekent niet dat we de lessen die OO biedt volledig negeren (hoe graag sommigen dat ook zouden willen beweren).
Onze switch-statement beschrijft enkele waarheden over Romeinse cijfers en hun gedrag.
We kunnen dit herstructureren door de data los te koppelen van het gedrag.
type RomanNumeral struct {
Value int
Symbol string
}
var allRomanNumerals = []RomanNumeral{
{10, "X"},
{9, "IX"},
{5, "V"},
{4, "IV"},
{1, "I"},
}
func ConvertToRoman(arabic int) string {
var result strings.Builder
for _, numeral := range allRomanNumerals {
for arabic >= numeral.Value {
result.WriteString(numeral.Symbol)
arabic -= numeral.Value
}
}
return result.String()
}Dit voelt veel beter. We hebben een aantal regels rond de cijfers als data gedeclareerd in plaats van verborgen in een algoritme, en we kunnen zien hoe we gewoon het Arabische getal berekenen en proberen symbolen aan ons resultaat toe te voegen als ze passen.
Werkt deze abstractie voor grotere getallen? Breid de testsuite uit zodat deze ook werkt voor het Romeinse getal 50, namelijk L.
Hier zijn enkele testgevallen, probeer ze te laten slagen.
{"40 gets converted to XL", 40, "XL"},
{"47 gets converted to XLVII", 47, "XLVII"},
{"49 gets converted to XLIX", 49, "XLIX"},
{"50 gets converted to L", 50, "L"},Hulp nodig? Je kunt zien welke symbolen je moet toevoegen in deze gist.
En de rest!
Hier zijn de resterende symbolen
100
C
500
D
1000
M
Gebruik dezelfde aanpak voor de overige symbolen. Het enige wat je hoeft te doen, is gegevens toevoegen aan zowel de tests als aan onze reeks symbolen.
Werkt je code ook voor 1984: MCMLXXXIV ?
Hier is mijn laatste testsuite
func TestRomanNumerals(t *testing.T) {
cases := []struct {
Arabic int
Roman string
}{
{Arabic: 1, Roman: "I"},
{Arabic: 2, Roman: "II"},
{Arabic: 3, Roman: "III"},
{Arabic: 4, Roman: "IV"},
{Arabic: 5, Roman: "V"},
{Arabic: 6, Roman: "VI"},
{Arabic: 7, Roman: "VII"},
{Arabic: 8, Roman: "VIII"},
{Arabic: 9, Roman: "IX"},
{Arabic: 10, Roman: "X"},
{Arabic: 14, Roman: "XIV"},
{Arabic: 18, Roman: "XVIII"},
{Arabic: 20, Roman: "XX"},
{Arabic: 39, Roman: "XXXIX"},
{Arabic: 40, Roman: "XL"},
{Arabic: 47, Roman: "XLVII"},
{Arabic: 49, Roman: "XLIX"},
{Arabic: 50, Roman: "L"},
{Arabic: 100, Roman: "C"},
{Arabic: 90, Roman: "XC"},
{Arabic: 400, Roman: "CD"},
{Arabic: 500, Roman: "D"},
{Arabic: 900, Roman: "CM"},
{Arabic: 1000, Roman: "M"},
{Arabic: 1984, Roman: "MCMLXXXIV"},
{Arabic: 3999, Roman: "MMMCMXCIX"},
{Arabic: 2014, Roman: "MMXIV"},
{Arabic: 1006, Roman: "MVI"},
{Arabic: 798, Roman: "DCCXCVIII"},
}
for _, test := range cases {
t.Run(fmt.Sprintf("%d gets converted to %q", test.Arabic, test.Roman), func(t *testing.T) {
got := ConvertToRoman(test.Arabic)
if got != test.Roman {
t.Errorf("got %q, want %q", got, test.Roman)
}
})
}
}Ik heb de
descriptionverwijderd, omdat ik vond dat de gegevens de informatie voldoende beschreven en ik steeds dezelfde regel aan het herhalen was.Ik heb een paar andere randgevallen toegevoegd om mezelf wat meer vertrouwen te geven. Met tabel-gebaseerde tests is dit heel goedkoop toe te voegen.
Ik heb het algoritme niet veranderd. Het enige wat ik moest doen was de allRomanNumerals-array uitbreiden.
var allRomanNumerals = []RomanNumeral{
{1000, "M"},
{900, "CM"},
{500, "D"},
{400, "CD"},
{100, "C"},
{90, "XC"},
{50, "L"},
{40, "XL"},
{10, "X"},
{9, "IX"},
{5, "V"},
{4, "IV"},
{1, "I"},
}Romeinse cijfers ontleden
We zijn er nog niet. Nu gaan we een functie schrijven die een Romeins cijfer naar een int converteert.
Schrijf eerst de test
We kunnen onze testcases hier opnieuw gebruiken met een kleine refactoring
Verplaats de cases-variabele buiten de test als een pakketvariabele in een var-blok.
func TestConvertingToArabic(t *testing.T) {
for _, test := range cases[:1] {
t.Run(fmt.Sprintf("%q gets converted to %d", test.Roman, test.Arabic), func(t *testing.T) {
got := ConvertToArabic(test.Roman)
if got != test.Arabic {
t.Errorf("got %d, want %d", got, test.Arabic)
}
})
}
}Let op, ik gebruik de slice-functionaliteit om nu slechts één van de tests uit te voeren (cases[:1]), omdat het een te grote stap is om al die tests in één keer te laten slagen.
Probeer de test uit te voeren
./numeral_test.go:60:11: undefined: ConvertToArabicSchrijf de minimale hoeveelheid code om de test te laten uitvoeren en de falende test output te controleren
Voeg de nieuwe functie definitie toe
func ConvertToArabic(roman string) int {
return 0
}De test kan nu worden uitgevoerd en zal falen
--- FAIL: TestConvertingToArabic (0.00s)
--- FAIL: TestConvertingToArabic/'I'_gets_converted_to_1 (0.00s)
numeral_test.go:62: got 0, want 1Schrijf genoeg code om de test te laten slagen
Je weet wat je te doen staat
func ConvertToArabic(roman string) int {
return 1
}Wijzig vervolgens de slice-index in onze test om naar de volgende testcase te gaan (bijv. cases[:2]). Laat deze test zelf slagen met de domste code die je kunt bedenken, en blijf ook voor de derde case domme code schrijven (het beste boek ooit, toch?). Hier is mijn domme code.
func ConvertToArabic(roman string) int {
if roman == "III" {
return 3
}
if roman == "II" {
return 2
}
return 1
}Door de domheid van echte code die werkt, kunnen we een patroon beginnen te zien zoals eerder. We moeten door de input itereren en iets bouwen, in dit geval een totaal.
func ConvertToArabic(roman string) int {
total := 0
for range roman {
total++
}
return total
}Schrijf eerst je test
Vervolgens gaan we naar de cases[:4] (IV), die nu niet meer werken omdat er 2 terugkomt, aangezien dat de lengte van de string is.
Schrijf genoeg code om de test te laten slagen
// earlier..
var allRomanNumerals = []RomanNumerals{
{1000, "M"},
{900, "CM"},
{500, "D"},
{400, "CD"},
{100, "C"},
{90, "XC"},
{50, "L"},
{40, "XL"},
{10, "X"},
{9, "IX"},
{5, "V"},
{4, "IV"},
{1, "I"},
}
// later..
func ConvertToArabic(roman string) int {
var arabic = 0
for _, numeral := range allRomanNumerals {
for strings.HasPrefix(roman, numeral.Symbol) {
arabic += numeral.Value
roman = strings.TrimPrefix(roman, numeral.Symbol)
}
}
return arabic
}Het is in feite het algoritme van ConvertToRoman(int), maar dan achterstevoren geïmplementeerd. Hier loopen we over de gegeven Romeinse cijferreeks:
We zoeken naar Romeinse cijfersymbolen uit
allRomanNumerals, van hoog naar laag, aan het begin van de reeks.Als we het voorvoegsel vinden, voegen we de waarde ervan toe aan
arabicen kappen we het voorvoegsel af.
Uiteindelijk geven we de som terug als een Arabisch getal.
HasPrefix(s, prefix) controleert of string s begint met prefix en TrimPrefix(s, prefix) verwijdert het prefix van s, zodat we verder kunnen met de resterende Romeinse cijfersymbolen. Het werkt met IV en alle andere testcases.
Je kunt dit implementeren als een recursieve functie, wat eleganter is (naar mijn mening), maar mogelijk ook langzamer. Ik laat dit aan jou over, samen met wat benchmark... tests.
Nu we de functies hebben om een Arabisch getal naar een Romeins cijfer om te zetten en omgekeerd, kunnen we onze tests nog een stap verder uitvoeren:
Een introductie tot op eigenschappen gebaseerde tests
Er zijn een paar regels op het gebied van Romeinse cijfers waarmee we in dit hoofdstuk hebben gewerkt
Er kunnen niet meer dan 3 dezelfde opeenvolgende symbolen zijn
Alleen I (1), X (10) en C (100) kunnen worden afgetrokken
Als we het resultaat van
ConvertToRoman(N)nemen en doorgeven aanConvertToArabic, moeten weNterugkrijgen.
De tests die we tot nu toe hebben geschreven, kunnen worden omschreven als 'voorbeeldtests', waarbij we voorbeelden aanleveren om de tooling te verifiëren.
Wat als we de regels die we over ons domein kennen, zouden kunnen gebruiken om deze op de een of andere manier toe te passen op onze code?
Eigenschap gebaseerde tests (Property based tests) helpen je hierbij door willekeurige data aan je code toe te voegen en te controleren of de regels die je beschrijft altijd kloppen. Veel mensen denken dat eigenschap gebaseerde tests voornamelijk om willekeurige data gaan, maar dat is niet waar. De echte uitdaging bij eigenschapsgebaseerde tests is een goed begrip van je domein, zodat je deze eigenschappen kunt schrijven.
Genoeg woorden, laten we eens kijken wat code
func TestPropertiesOfConversion(t *testing.T) {
assertion := func(arabic int) bool {
roman := ConvertToRoman(arabic)
fromRoman := ConvertToArabic(roman)
return fromRoman == arabic
}
if err := quick.Check(assertion, nil); err != nil {
t.Error("failed checks", err)
}
}Grondslag van eigendom
Onze eerste test controleert of als we een getal naar het Romeins omzetten, we met onze andere functie het getal weer terugkrijgen naar het oorspronkelijke getal.
Gegeven een willekeurig getal (bijv.
4).Roep
ConvertToRomanaan met het willekeurige getal (zouIVterug moeten geval bij4).Neem het verkregen resultaat en geef het door aan
ConvertToArabic.Het bovenstaande zou ons de originele input terug moeten geven (
4).
Dit voelt als een goede test om ons vertrouwen te vergroten, want het zou moeten werken als er een bug in een van beide zit. De enige manier waarop het zou kunnen slagen is als ze dezelfde soort bug hebben; wat niet onmogelijk is, maar onwaarschijnlijk lijkt.
Technische uitleg
We gebruiken de testing/quick package uit de standaard bibliotheek
Als we van onderaf lezen, bieden we snel een quick.Check aan voor een functie die wordt uitgevoerd op een aantal willekeurige invoeren. Als de functie false retourneert, wordt deze beschouwd als mislukt door de controle.
Onze bovenstaande assertion neemt een willekeurig getal en voert onze functies uit om de eigenschap te testen.
De test uitvoeren
Probeer het eens uit; je computer kan dan even vastlopen, dus sluit het programma af als je je verveelt :)
Wat is er aan de hand? Probeer het volgende toe te voegen aan de assertiecode.
assertion := func(arabic int) bool {
if arabic < 0 || arabic > 3999 {
log.Println(arabic)
return true
}
roman := ConvertToRoman(arabic)
fromRoman := ConvertToArabic(roman)
return fromRoman == arabic
}Je zou dan zoiets moeten zien:
=== RUN TestPropertiesOfConversion
2019/07/09 14:41:27 6849766357708982977
2019/07/09 14:41:27 -7028152357875163913
2019/07/09 14:41:27 -6752532134903680693
2019/07/09 14:41:27 4051793897228170080
2019/07/09 14:41:27 -1111868396280600429
2019/07/09 14:41:27 8851967058300421387
2019/07/09 14:41:27 562755830018219185Alleen al het uitvoeren van deze zeer eenvoudige eigenschap heeft een fout in onze implementatie blootgelegd. We gebruikten int als invoer, maar:
Je kunt geen negatieve getallen gebruiken met Romeinse cijfers
Gegeven onze regel van maximaal 3 opeenvolgende symbolen kunnen we geen waarde groter dan 3999 weergeven (nou ja, soort van) en
intheeft een veel hogere maximumwaarde dan 3999.
Geweldig! We zijn gedwongen om dieper na te denken over ons domein, wat een echte kracht is van eigenschap gebaseerde tests.
Het is duidelijk dat int niet het beste type is voor deze toepassing. Wat als we iets geschikters zouden proberen?
Go heeft typen voor unsigned integers, wat betekent dat ze niet negatief kunnen zijn; dat sluit meteen een bug in onze code uit. Door 16 toe te voegen, wordt het een 16-bits integer die maximaal de waarde 65535 kan opslaan. Dat is nog steeds te groot, maar brengt ons wel dichter bij wat we nodig hebben.
Probeer de code bij te werken zodat deze uint16 gebruikt in plaats van int. Ik heb de assertion in de test bijgewerkt om iets meer zichtbaarheid te geven.
Let erop dat je in
roman.goook de variabelearabicmoet aanpassen naaruint16(de test zal je dit vertellen). Wat misschien een grotere zoektocht is, is de foutmelding die je krijgt voor de regelarabic += numeral.Value. Deze melding krijg je omdat wearabicinConvertToArabichebben gedeclareerd metarabic := 0. Deze manier van declareren is goed, maar Go zal er vanuit gaan dat we de0moeten behandelen als eenintwaarde. De foutmelding gaat er dus over dat je eenintwaarde en eenuint16waarde bij elkaar op probeert te tellen. Omdat Go een typed language is, zal dat niet gaan. Pas daaromarabic := 0aan naar var arabicuint16 = 0om de code te laten werken.
assertion := func(arabic uint16) bool {
if arabic > 3999 {
return true
}
t.Log("testing", arabic)
roman := ConvertToRoman(arabic)
fromRoman := ConvertToArabic(roman)
return fromRoman == arabic
}Merk op dat we nu de invoer loggen met de logmethode van het test-framework. Zorg ervoor dat je de opdracht go test uitvoert met de vlag -v om de extra uitvoer te tonen (go test -v).
Als je de test uitvoert, worden ze daadwerkelijk uitgevoerd en kun je zien wat er getest wordt. Je kunt de test meerdere keren uitvoeren om te zien of onze code goed presteert op basis van de verschillende waarden! Dit geeft me veel vertrouwen dat onze code werkt zoals we willen.
Het standaard aantal runs dat quick.Check uitvoert is 100, maar je kunt dit wijzigen via een configuratie.
if err := quick.Check(assertion, &quick.Config{
MaxCount: 1000,
}); err != nil {
t.Error("failed checks", err)
}Further work
Kun je een eigenschappen tests schrijven die de andere eigenschappen controleren die we hebben beschreven?
Kun je een manier bedenken om het zo te maken dat het voor iemand onmogelijk is om de code aan te roepen met een getal groter dan 3999?
Je zou een foutmelding terug kunnen geven
Of je maakt een nieuw type aan dat getallen groter dan 3999 niet kan vertegenwoordigen
Wat zou je beste oplossing zijn denk je?
Samenvattend
Meer TDD-oefeningen met iteratieve ontwikkeling
Vond je het idee om code te schrijven die 1984 omzet in MCMLXXXIV in het begin intimiderend? Voor mij wel, en ik schrijf al heel lang software.
De truc is, zoals altijd, om met iets eenvoudigs te beginnen en kleine stapjes te zetten.
Op geen enkel punt in dit proces hebben we grote sprongen gemaakt, grote refactoringen doorgevoerd of een puinhoop gemaakt.
Ik hoor iemand cynisch zeggen: "Dit is maar een kata." Daar kan ik niet tegenin gaan, maar ik hanteer nog steeds dezelfde aanpak voor elk project waaraan ik werk. Ik lever nooit meteen een groot gedistribueerd systeem af; ik zoek het simpelste wat het team kan leveren (meestal een "Hello world"-website) en itereer dan op kleine stukjes functionaliteit in beheersbare brokken, net zoals we hier deden.
De kunst is om te weten hoe je het werk moet opsplitsen. Met wat oefening en een aantal fijne TDD-technieken kun je dat leren en op weg helpen.
Eigenschap gebaseerd tests
Ingebouwd in de standaardbibliotheek
Als je manieren kunt bedenken om je domeinregels in code te beschrijven, vormen deze een uitstekend hulpmiddel om je meer zelfvertrouwen te geven over je code
Dwingt je om goed na te denken over je domein
Potentieel een mooie aanvulling op je testsuite
Wat extra's
Dit boek is afhankelijk van waardevolle feedback van de community. Dave is een enorme hulp in vrijwel elk hoofdstuk. Maar hij had een flinke tirade over mijn gebruik van 'Arabische cijfers' in dit hoofdstuk, dus, in het belang van volledige openheid, hier is wat hij zei.
Ik ga gewoon uitleggen waarom een waarde van het type
intniet echt een 'Arabisch cijfer' is. Misschien ben ik wel veel te precies, dus ik begrijp het helemaal als je me compleet negeert.Een cijfer is een teken dat gebruikt wordt bij het weergeven van getallen – van het Latijnse woord voor 'vinger', omdat we er meestal tien hebben. In het Arabische (ook wel Hindoe-Arabische) getallenstelsel zijn er tien. Deze Arabische cijfers zijn:
0 1 2 3 4 5 6 7 8 9Een nummer is de weergave van een getal met behulp van een verzameling cijfers. Een Arabisch nummer is een getal dat wordt weergegeven door Arabische cijfers in een tientallig positioneel getallenstelsel. We zeggen 'positioneel' omdat elk cijfer een andere waarde heeft, afhankelijk van de positie in het cijfer. Dus
1337De
1heeft de waarde duizend omdat het het eerste cijfer is van een getal met vier cijfers.Romeinse cijfers bestaan uit een beperkt aantal cijfers (
I,V, enz.), voornamelijk als waarden om het cijfer te vormen. Er is wat positionele informatie, maar meestal staatIaltijd voor 'één'.Dus, met deze informatie, is een
intdan een 'Arabisch getal'? Het idee van een getal is helemaal niet verbonden met de representatie ervan. We kunnen dit zien als we ons afvragen wat de juiste representatie van dit getal is:255 11111111 twee-honderd en vijf-en-vijftig FF 377Ja, dit is een strikvraag. Ze zijn allemaal correct. Ze representeren hetzelfde getal in respectievelijk het decimale, binaire, Nederlandse, hexadecimale en octale talstelsel.
De representatie van een getal als cijfer is onafhankelijk van zijn eigenschappen als getal. Dit zien we bijvoorbeeld als we kijken naar gehele getallen in Go:
0xFF == 255 // trueEn hoe we gehele getallen in een formatstring kunnen afdrukken:
n := 255 fmt.Printf("%b %c %d %o %q %x %X %U", n, n, n, n, n, n, n, n) // 11111111 ÿ 255 377 'ÿ' ff FF U+00FFWe kunnen hetzelfde gehele getal zowel als hexadecimaal als als Arabisch (decimaal) cijfer schrijven.
Dus wanneer de functie aanroep eruitziet als
ConvertToRoman(arabic int) string, gaat het om een aanname over hoe deze wordt aangeroepen. Omdatarabicsoms wordt geschreven als een decimaal geheel getal (literal):ConvertToRoman(255)Maar het zou net zo goed geschreven kunnen zijn als hexadecimaal getal:
ConvertToRoman(0xFF)Eigenlijk 'converteren' we helemaal geen Arabisch cijfer, we 'printen' - en representeren - een
intals een Romeins cijfer - enints zijn geen cijfers, Arabisch of anderszins; het zijn gewoon getallen. De functieConvertToRomanlijkt meer opstrconv.Itoain die zin dat het eenintomzet in eenstring.Maar elke andere versie van de kata maakt geen onderscheid tussen deze twee, dus ¯_(ツ)_/¯
Laatst bijgewerkt