Hopp til hovedinnhold

Prosess og rådgivning / 5 minutter /

Testing: Less is More!

Vel, jeg innrømmer at overskriften er noe spekulativ, men les videre så forstår du...

De aller fleste som jobber med software kan være enige om at testing er lurt. Og jeg tror også at alle kan være enige om at det er nyttig å teste mest mulig med minst mulig kode.

Man kan si at testing er en av mange måter å få tilbakemelding om at programmet ditt gjør det du mener det skal gjøre, og at det har den effekten du håper det skal ha.

Man har flere varianter av testing. Man har den enkleste formen med enhetstesting. Den er lynrask og kan gi tilbakemelding med en gang koden bygges. Så har man integrasjonstesting, der sammensetning av systemer testes, men fortsatt automatisk. Litt mer krevende er manuell systemtesting, som krever installasjon og manuelle rutiner. Kanskje har man pilotkunder som tester nye deler av programmet ditt i et produksjonsmiljø før det slippes løs på et større marked? Til slutt har man produksjon, der sluttbrukerne hver dag tester systemet for deg.

Det trente øyet ser at alle disse testvariantene ligger på et kontinuum, der de enkleste testene gir raskest tilbakemelding og er enklest å lage, mens "testene" i produksjon kan være adskillig mer krevende å reagere på.

Men vi kan gjøre det enda bedre! For det finnes også noe som kan gjøres før man i det hele tatt kommer til testing! Tenk deg det! Slippe å vedlikeholde tester, og samtidig sove godt om natta!

For den aller raskeste tilbakemeldingen får du av kompilatoren. Den tester om programmet ditt er gyldig! (Vel - syntaktisk gyldig da i hvert fall, og semantisk med tanke på programmeringsspråket, men det er ikke så viktig).

For Java baserer denne herlige ideen seg på at man bruker subklasser for å beskrive de ulike tilstandene en entitet eller verdi kan ha. Man begrenser mulighetene til å opprette objekter ved å ha private konstruktorer og bruke statiske metoder som sjekker input før objektet returneres. Cluet er at man ikke vet hvilken subklasse man får tilbake, og at man med noen triks sørger for at man må sjekke for alle mulige tilstander - hvis ikke kompilerer ikke koden.

La oss lage oss et lite case som ligner litt på det vi ser fra virkeligheten, men samtidig er så enkelt at det ikke blir for mye arbeid. Vi kan for eksempel tenke oss at vi lager en applikasjon som blant annet skal håndtere kontaktinformasjon. Vi starter med epost.

Hvis vi skal lage et skikkelig enterprisey system bruker vi sikkert jax-rs + jackson. Da kunne vi ha brukt annotasjoner for å være sikre på at data som kommer inn er korrekt:

1public class ContactInfo {
2 
3    @Email
4    @NotNull
5    private String email;
6 
7    public ContactInfo(String email) {
8        this.email = email;
9    }
10 
11    public String getEmail() {
12        return email;
13    }

og bruke den slik:

1public class Example1 {
2 
3    public static void main(String[] args) {
4        ValidatorFactory factory =
5            Validation.buildDefaultValidatorFactory();
6 
7        Validator validator =
8            factory.getValidator();
9 
10        ContactInfo contactInfo =
11            new ContactInfo("ola.normann@test.org");
12 
13        Set<ConstraintViolation<ContactInfo>> constraintViolations =
14            validator.validate(contactInfo);
15 
16        //Skriver ut et tomt sett
17            System.out.println(constraintViolations);
18 
19 
20        ContactInfo feilContactInfo =
21            new ContactInfo("ola.normann_test");
22 
23        Set<ConstraintViolation<ContactInfo>> feilConstraintViolations =
24            validator.validate(feilContactInfo);
25 
26        //Skriver ut et sett med feil
27            System.out.println(feilConstraintViolations);
28 
29    }
30}

Vel og bra. Men det er litt styr at overalt man oppretter en ContactInfo også må gjøre en validering. Når du tenker etter, så bør man gjøre en validering hver gang man bruker et ContactInfo objekt. Man vet jo ikke om objektet er validert fra før! Vi er også avhengig av et rammeverk som gjør dette for oss. Og vi blir ikke varslet dersom vi glemmer å validere i det hele tatt.

La oss se om vi kan gjøre det slik at vi får en kompileringsfeil dersom vi prøver å bruke et uvalidert objekt.

Forrige artikkel handlet om Validering. La oss gjenbruke klassen vi lagde der: Validated. La oss lage en egen domeneklasse for epost, og modde litt på caset vårt. Vi modder på ContactInfo, og lager klassen EmailAdress. Vi må være helt sikre på at EmailAdress ikke endrer seg etter at den er validert, det betyr at vi må gjøre den immutable. Det får vi til ved å sørge for at alt innhold i klassen er final, og at alt innholdet er immutable. String er immutable, så da kan vi lage klassen:

1public class EmailAddress {
2 
3    final String value;
4 
5    public EmailAddress(String value) {
6        this.value = value;
7    }
8}

Nå må vi gjøre det helt sikkert at det ikke går an å opprette en EmailAdress som har ugyldig syntaks. Dette får vi til ved å gjøre konstruktoren private, og lage en factory metode som returnerer Validated. Dette gjør det umulig å få tak i eller bruke et epostobjekt som ikke er validert og gyldig. Når man nå får tak i et Email objekt vet man at det er gyldig, og man trenger ikke sjekke dette flere steder i applikasjonen. Kompilatoren vil varsle deg dersom du prøver opprette en uten å validere først.

1public class EmailAddress {
2 
3    final String value;
4 
5    private EmailAddress(String value) {
6        this.value = value;
7    }
8 
9    public static Validated<EmailAddress> of(String value) {
10        return
11            EmailValidator.getInstance().isValid(value) ?
12                Validated.valid(new EmailAddress(value)) :
13                Validated.fail("Feil format");
14    }
15}

Bonus med denne framgangmåten er at det er lett å bruke, siden man ikke trenger rammeverkstøtte.

For å gjøre det litt mer interessant lager vi et tilsvarende for telefonnummer også:

1public class Phonenumber {
2 
3    final NonEmptyList<Digit> digits;
4 
5    private Phonenumber(NonEmptyList<Digit> digits) {
6        this.digits = digits;
7    }
8 
9    public static Validated<Phonenumber> of(String numberAsString) {
10        List<Digit> digitList =
11        Option.somes(Stream.fromString(numberAsString).map(Digit::fromChar)).toList();
12        return
13            digitList.isEmpty() ?
14                Validated.fail("Feil format på input, det må være minst ett tall") :
15                Validated.valid(new Phonenumber(NonEmptyList.nel(digitList.head(), digitList.tail())));
16    }
17 
18}

Det kan virke som litt pes å lagre tallene i et telefonnummer som en ikke-tom liste med siffer. Men nå er vi sikre at det ikke er en random tekststreng, vi vet at listen ikke er tom, og vi vet at det er gyldige siffer på hver plass. Vi trenger ikke teste det! Dersom vi lager litt mer kompliserte regler for gyldige telefonnummer, så må vi lage en test for at disse stemmer for alle gyldige telefonnummer. Det høres ut som sirkellogikk, så det fikser vi en senere artikkel.

Vi setter nå dette inn i ContactInfo slik vi lærte forrige artikkel:

1...
2    public static void main(String[] args) {
3 
4        Validated<ContactInfo> validatedInfo =
5            Validated.accum(
6                EmailAddress.of("ola.normann@test.com"),
7                Phonenumber.of("12345678"),
8                ContactInfo::new
9            );
10             
11        ...

Ok, nå har vi gjort to konverteringer, uten at det egentlig går an å skrive en meningsfull test for det. Fordi det ikke kan være feil, kompilatoren sjekker det for oss.

Dette var enkelt og trivielt. La oss utvide caset vårt litt. Epostadressen må jo bekreftes av brukeren. Det øker sannsynligheten for at den stemmer (la oss anta at vi ikke vil eller kan bruke OpenId Connect for pålogging).

Vi endrer EmailAddress til et interface med to implementasjoner: Unconfirmed og Confirmed, og så definerer vi en fold() metode. Denne fungerer akkurat som en visitor, bare at den returnerer en verdi. Vi lar også fold være den eneste måten å hente ut informasjon fra EmailAddress på.

Vi lager to muligheter for å opprette en bekreftet epostadresse på: En for standard forretningslogikk der vi markerer en ubekreftet adresse som bekreftet ved hjelp av et "bevis" (i dette tilfellet en timestamp), og en metode vi kan bruke for eksempel for deserialisering. Den sistnevnte gir vi et navn som tydeliggjør at den bruker man bare unntaksvis.

1public interface EmailAddress {
2 
3    //Dette er eneste måten å hente ut tilstanden fra EmailAdress på
4    <T> T fold(
5        Function<Unconfirmed, T> onUnconfirmed,
6        Function<Confirmed, T> onConfirmed
7    );
8 
9    //Brukes når vi skal opprett en epostadresse fra brukeren
10    static Validated<EmailAddress> of(String value) {
11        return
12            EmailValidator.getInstance().isValid(value) ?
13                Validated.valid(new Unconfirmed(value)) :
14                Validated.fail("Feil format");
15    }
16     
17    //Brukes kun til deserialisering der vi stoler på datagrunnlaget
18    static Validated<EmailAddress> unsafeCreateConfirmed(Instant instant, String value) {
19        return
20            EmailValidator.getInstance().isValid(value) ?
21                Validated.valid(new Confirmed(instant,value)) :
22                Validated.fail("Feil format");
23    }
24 
25    class Unconfirmed implements EmailAddress {
26 
27        public final String value;
28 
29        public Unconfirmed(String value) {
30            this.value = value;
31        }
32 
33        //Brukes når man skal bekrefte en epostadresse. Her bruker vi et tidsstempel som "bevis" på at 
34        //bekreftelsen har skjedd. Denne skal gjøre det vanskelig å opprette bekreftede epostadresser
35        //når man er litt bevisstløs i gjerningsøyeblikket
36        public Confirmed confirm(Instant timestamp){
37            return new Confirmed(timestamp, value);
38        }
39        
40        @Override
41        public <T> T fold(Function<Unconfirmed, T> onUnconfirmed, Function<Confirmed, T> onConfirmed) {
42            return onUnconfirmed.apply(this);
43        }
44 
45 
46    class Confirmed implements EmailAddress {
47 
48        public final Instant timestamp;
49        public final String value;
50 
51        private Confirmed(Instant timestamp, String value) {
52            this.timestamp = timestamp;
53            this.value = value;
54        }
55 
56        @Override
57        public <T> T fold(Function<Unconfirmed, T> onUnconfirmed, Function<Confirmed, T> onConfirmed) {
58            return onConfirmed.apply(this);
59        }
60    }
61}

For å hente ut data blir vi nå tvunget til å bruke fold(), og da må vi håndtere begge de mulige tilstandene til EmailAddress. Skipper vi det får vi en kompileringsfeil.

Så dersom vi for eksempel skal sende ut et ukessammendrag på mail til en bruker, så lager vi oss en sammendrags-klasse som inneholder en EmailAdress.Confirmed. På denne måten kan vi ikke opprette et sammendragsobjekt uten en bekreftet epostadresse.

1public class DigestMessage {
2 
3    public final EmailAddress.Confirmed confirmedMail;
4    public final String subject;
5 
6    //Kun en bekreftet epostadresse aksepteres her
7    public DigestMessage(EmailAddress.Confirmed confirmedMail, String subject) {
8        this.confirmedMail = confirmedMail;
9        this.subject = subject;
10    }
11}

For å opprette en DigestMessage må vi nå bruke EmailAddress.fold():

1public static void main(String[] args) {
2 
3    final String digestSubject =
4        "Dette er en oppsummering";
5 
6    //Vi skal sende ut en oppsummering til en bruker, men
7    // 1) Brukeren må finnes
8    // 2) Brukeren må ha bekreftet eposten sin
9    final Optional<ContactInfo> maybeInfo =
10        Database.infoForId("a"); //Prøv med b eller c også
11 
12    final Validated<DigestMessage> validatedDigest =
13        maybeInfo
14            .map(contactInfo -> contactInfo.email)
15            .map(emailAddress -> emailAddress.fold( //her bruker vi fold 
16                unconfirmed -> Validated.<DigestMessage>fail("Epostadressen er ikke bekreftet"), //kjøres dersom post er ubekreftet
17                confirmed -> Validated.valid(new DigestMessage(confirmed, digestSubject)))) //kjøres dersom bekreftet
18            .orElseGet(() -> Validated.fail("Brukeren finnes ikke i databasen"));
19 
20    System.out.println(validatedDigest);
21 
22}

Oi. Mange vil tenke at dette ser veldig ukjent og rotete ut. Du tenker kanskje at exceptions eller casting, eller sågar bare å sette et flagg i Emailaddress hadde vært mer lettvint. Og svaret er nok utvilsom ja. Men da åpner du opp for at man kan havne i en ugyldig tilstand! Da ville det være mulig å sende ut mail til en ubekreftet adresse. Dette må du dermed skrive tester for å validere. Og siden de testene må sjekke tilstandsendringen over tid - nemlig at en mail først ikke kan sendes ut, og så sendes ut - blir det adskillig mer arbeid å vedlikeholde testene, enn å bare legge på et ekstra lag med typesikkerhet. Kanskje er det verdt å øve seg på å lese slik kode allikevel da? Jeg mener -mindre testing?

En bonus med å være så eksplisitt med de ulike tilstandene et objekt kan ha er at man blir tvunget til å tenke gjennom grensetilfellene fra starten: Hva om brukeren ikke finnes (den kan jo bli slettet i mellomtiden)? Hva skal systemet gjøre dersom man forsøke sende mail til en ubekreftet adresse? Vær ærlig, dette er problemstillinger vi ofte skyver på til det smeller.

Koden som brukes i eksemplene finnes på https://github.com/kantega/correct-by-construction, jeg anbefaler at du tar en titt.