Tutti noi che lavoriamo pressoché quotidianamente con chiamate API, abbiamo trovato grandi compagni di viaggio nei Client dedicati, grazie ai quali possiamo:
- Verificare il corretto funzionamento delle rotte che stiamo sviluppando.
- Provare il reale funzionamento degli entrypoint che ci vengono forniti da terze parti (ogni tanto la manualistica riserva interessanti sorprese).
- Capire cosa sta succedendo nel momento in cui ci viene aperto un bug.
Per ottemperare queste attività è sufficiente impostare una request ed eseguirla, leggendo lo status della response e, se disponibile, valutare visivamente il payload restituito in risposta. Ma Bruno, per noi, può fare molto di più, aiutandoci nel controllo delle response mediante le sue “Assertion” e, ove non sufficienti allo scopo, utilizzando i suoi “Script”.
Anche Postman, ad esempio, è in grado di fare tutto ciò di cui parleremo in questo post, è ovvio che non siano prerogative esclusive di Bruno. Quello su cui mi sento di porre l’accento è la semplicità,nonché l’elasticità, con cui Bruno ci permette di farlo.
A scopo puramente dimostrativo userò alcune API di MusicBrainz.org, una vera e propria enciclopedia open source dedicata alla musica, un database di Artisti, Generi, Dischi e chi più ne ha più ne metta (eh no, le registrazioni non ci sono…) tutto gestito dalla sua Community.
Assertion
Il primo strumento di cui vorrei parlarvi è la Assertion che ci permette, in maniera semplice e diretta, di verificare se le risposte che otteniamo possiedono le caratteristiche che ci aspettiamo per considerarle valide.
Lo strumento è molto semplice ma, nonostante ciò, sono convinto che copra un’ampia varietà di esigenze, sufficiente per molti Dev.
La forma della tipica asserzione è la seguente:
<expression> <operator> <value>
Una “expression” rappresenta un campo della response che vogliamo testare, che indicheremo in “dot notation” partendo dall’oggetto “res”, che mette a disposizione le seguenti proprietà:
status | Il codice numerico dello status di risposta. Es: 200 |
statusText | Il codice testuale dello status di risposta. Es: OK |
headers | Oggetto che rappresenta gli Headers in risposta. |
body | L’eventuale payload ottenuto in risposta. |
responseTime | Il tempo di esecuzione della chiamata, espresso in millisecondi. |
[PITFALL] Bruno converte la risposta JSON in un oggetto JavaScript, in modo tale da poterlo interrogare con la ben nota “dot notation”. Nel caso in cui una proprietà contenga un carattere invalido per JavaScript (ad esempio il trattino, “-“) è necessario indirizzarla sfruttando una sintassi differente. Ad esempio:
res.body.life-span.begin => Errore
Code language: PHP (php)
res.body.[‘life-span’].begin => OK !
Code language: PHP (php)
Il “value” è il valore con cui confrontare la sopraccitata expression. Questo può essere una costante oppure una variabile (definita a monte della richesta e specificata tra doppie parentesi graffe).
Per quanto riguarda l’“operator”, questo indica come vogliamo porre in relazione expression e value, per verificarne la validità (secondo i nostri criteri). Gli operatori attualmente previsti sono i seguenti:
Assert… | è verificata se… |
equals | il valore di expression è uguale a quanto specificato in Value. |
notEquals | il valore di expression è diverso rispetto a quanto specificato in Value. |
gt | il valore di expression è maggiore (>) di quanto specificato in Value. |
gte | il valore di expression è maggiore o uguale (>=) di quanto specificato in Value. |
lt | il valore di expression è minore (<) di quanto specificato in Value. |
lte | il valore di expression è minore o uguale (<=) di quanto specificato in Value. |
in | il valore di expression è tra quelli specificati in Value. I valori di confronto devono essere specificati tra [] e separati da virgola, ad esempio:[ “A”, “B”, “C” ] |
notIn | il valore di expression NON è tra quelli specificati in Value. Il formato di Value è lo stesso utilizzato con l’operatore “in”. |
contains | il valore di expression contiene quanto specificato in Value (il confronto ha senso sia nel caso in cui si parli di stringhe che di array). |
notContains | il valore di expression NON contiene quanto specificato in Value. |
length | il valore di expression è:Una stringa la cui lunghezza equivale al valore specificato in Value.Un array il cui numero di elementi equivale al valore specificato in Value. |
matches | il valore di expression rispetta la Regular Expression indicata in Value. |
startsWith | il valore di expression inizia con quanto specificato in Value. |
endsWith | il valore di expression termina con quanto specificato in Value. |
between | il valore di expression è compreso tra i due valori numerici specificati (separati da virgola). |
isEmpty | il valore di expression è una stringa vuota oppure un array vuoto. |
isNull | il valore di expression è null. |
isUndefined | il campo indicato da expression NON esiste. |
isDefined | il campo indicato da expression esiste (con qualunque valore, anche null). |
isTruthy | il valore di expression è “true”. |
isFalsy | il valore di expression è “false”. |
isJson | il valore di expression è un oggetto JSON. |
isNumber | il valore di expression è un numero. |
isString | il valore di expression è una stringa. |
isBoolean | il valore di expression è un boolean. |
isArray | il valore di expression è un array. |
Bene, a questo punto abbiamo tutti i mattoncini necessari a costruire le nostre Assertion, così da verificare che le chiamate rispondano come ci aspettiamo.
Chiediamo quindi a MusicBrainz i dati di dettaglio di un Gruppo scelto assolutamente a caso, ovvero i “Green Day” e lavoriamo sulla risposta che ci viene fornita chiamando l’API:
La risposta, in formato JSON, è la seguente:
{
"type": "Group",
"type-id": "e431f5f6-b5d2-343d-8b36-72607fffb74b",
"country": "US",
"end-area": null,
"id": "084308bd-1654-436f-ba03-df6697104e19",
"gender-id": null,
"gender": null,
"begin-area": { "name": "Berkeley",
"disambiguation": "",
"sort-name": "Berkeley",
"type-id": null,
"type": null,
"id": "f9719692-c39a-4d9c-bf8c-dd4035b09534" },
"disambiguation": "",
"sort-name": "Green Day",
"life-span": { "end": null,
"begin": "1989",
"ended": false },
"ipis": [],
"name": "Green Day",
"area": { "iso-3166-1-codes": [ "US" ],
"id": "489ce91b-6658-3307-9877-795b68554c98",
"sort-name": "United States",
"type-id": null,
"disambiguation": "",
"type": null,
"name": "United States" },
"isnis": [ "0000000122711282" ]
}
Code language: JSON / JSON with Comments (json)
Proviamo ora ad implementare qualche assertion usando gli operatori di cui abbiamo appena parlato.
Expression | Operator | Value | Description |
res.status | equals | 200 | Lo status di risposta è 200. |
res.status | notEquals | 400 | Lo status di risposta NON è 400. |
res.responseTime | lte | 500 | Il tempo di risposta è inferiore (o uguale) a 500ms. |
res.body.name | equals | Green Day | Il campo name del body corrisponde a “Green Day” |
res.body.name | startsWith | Green | Il campo name del body inizia per “Green” |
res.body.name | endsWith | Day | Il campo name del body termina per “Day” |
res.body.country | equals | US | Il campo country del body corrisponde a US. |
res.body.country | matches | /^U[S|K]$/ | Il campo country del body rispetta la Regular Exspression indicata, nella fattispecie la prima lettera è una “U”, la seconda una “S” o una “K”. |
res.body.country | in | [“US”,”UK”] | Il campo country del body corrisponde ad uno degli item dell’array specificato. |
res.body.country | notIn | [“IT”,”FR”,”ES”] | Il campo country del body NON corrisponde ad uno degli item dell’array specificato. |
res.body.ismis | length | 1 | L’array corrispondente al campo ismis del body ha un solo elemento. |
res.body.country | length | 2 | La lunghezza della stringa corrispondente al campo country è di due caratteri. |
res.body.life-span.begin | equals | “1989” | ERRORE – il carattere “-“ non è consentito ! |
res.body.[‘life-span’].begin | equals | “1989” | La stringa corrispondente al campo begin dell’oggetto life-span equivale a “1989”.Trattandosi di stringa, è necessario racchiuderlo tra doppi apici. |
res.body.name | contains | Day | Il campo name del body contiene la parola “Day”. |
res.body.name | notContains | Sfera | Il campo name del body NON contiene la parola “Sfera”. |
res.body.area[‘iso-3166-1-codes’] | contains | US | Il campo iso-3166-1-codes contiene, in quanto array, l’item “US”. |
res.body.area[‘iso-3166-1-codes’] | notContains | IT | Il campo iso-3166-1-codes NON contiene, in quanto array, l’item “IT”. |
res.responseTime | between | 30,500 | Il tempo di risposta è compreso tra 30 e 500 millisecondi. |
res.body.isnis.length | between | 1,3 | La lunghezza dell’array isnis è compresa tra 1 e 3. |
res.body.name.length | between | 5,10 | La lunghezza del campo name è compresa tra 5 e 10 caratteri. |
res.body.ipis | isEmpty | Il campo ipis del body è un array vuoto. | |
res.body.disambiguation | isEmpty | Il campo disambiguation è una stringa vuota. | |
res.body.gender | isNull | Il campo gender è null. | |
res.body.notexists | isUndefined | Il campo notexists NON è definito nel body (non esiste) | |
res.body.gender | isDefined | Il campo gender del body è definito (nonostante sia null). | |
res.body[‘life-span’].ended | isFalsy | Il campo ended è false. | |
res.body | isJson | Il body è un oggetto JSON. | |
res.body.area | isJson | il campo area del body è un oggetto JSON. | |
res.body[‘life-span’].ended | isBoolean | il campo ended dell’oggetto life-span è di tipo boolean. | |
res.responseTime | isNumber | il tempo di risposta è un numero. | |
res.body.type | isString | il campo type è una stringa. |
Dopo aver impostato tutte queste assertion, possiamo provare ad eseguire la nostra Request. Nella parte destra della finestra di Bruno, in corrispondenza del Tab “Tests” troveremo il risultato delle nostre assertion:
Notare il numero “1” in apice sul nome del Tab, che indica la quantità di test falliti (nel nostro caso ne abbiamo uno, che fallisce a causa dell’ormai noto trattino nel nome del campo “life-span”), che vengono evidenziati in rosso nell’area sottostante.
Test
“Quando il gioco si fa duro, i duri cominciano a giocare” (cit.)
Sebbene le assertion siano in grado di coprire la maggior parte delle esigenze di testing di noi Dev, Bruno ci offre una ghiotta alternativa, ovvero quella dei Test, che si basano sulle interfacce expect e assert di Chai, permettendoci il controllo totale di quanto restituito dalle requests.
Possiamo inserire i Test, a livello di request, selezionando l’omonimo tab, come illustrato dall’immagine seguente:
All’inizio del file di test andiamo a definire le variabili che useremo:
// Extract body from response
const _payload = res.getBody();
// Extract Response Status
const _status = res.getStatus();
Code language: JavaScript (javascript)
Non è assolutamente obbligatorio, ma usare variabili “semplici” al posto dei metodi, renderà il codice più leggibile.
Ora, se dovessimo riscrivere via test l’assert di verifica dello status di risposta (controllando che corrisponda a “OK”, ovvero 200) potremmo utilizzare indifferentemente expect:
test("[Using Expect] Response Status should be 200", function() {
expect(_status).to.equal(200);
});
Code language: JavaScript (javascript)
oppure assert:
Se invece volessimo valutare il tempo di risposta per verificare che rientri in un intervallo di tempo specifico, dovremmo rivolgerci a expect:
test("[Using Expect] ResponseTime is between 30 and 500", function() {
expect(_status).to.be.gte(30).and.lte(500);
});
Code language: JavaScript (javascript)
Ancora, verifichiamo che la carriera dei Green Day sia iniziata nel 1989 (il dato viene restituito come stringa):
test("[Using Expect] Career should start in 1989", function() {
expect(_payload['life-span'].begin).to.equal("1989");
});
Code language: JavaScript (javascript)
e se volessimo usare assert:
test("[Using Assert] Career should start in 1989", function() {
assert.equal(_payload['life-span'].begin, "1989");
});
Code language: JavaScript (javascript)
Proviamo adesso ad implementare qualcosa che le assert non ci permettono: andiamo a verificare che ogni item dell’array isnis sia effettivamente una stringa che inizia con il carattere “0” e termina con il carattere “2”. Per fare ciò usiamo expect:
test("[Using Expect] Check Items in isnis array", function() {
expect(_payload.isnis).to.satisfy(function (items){
return items.every(function(item){
return item.startsWith("0") && item.endsWith("2");
})
})
});
Code language: JavaScript (javascript)
Bene, a questo punto il meccanismo di base dei Test non ha più segreti… o forse no? C’è ancora un’interessante particolarità di cui vorrei parlarvi.
Lo stesso tab “Tests” esiste anche ai livelli di Collection e delle eventuali Directory che raggruppano le nostre chiamate. Come facilmente intuibile, ci viene consentito di inserire dei test anche a questi livelli in modo tale che vengano eseguiti su tutte le request figlie.
La struttura del progetto a cui mi riferisco (troverete i riferimenti in calce al post) è la seguente:
- BrunoSample (collection)
- MusicBrainz (dir)
- Artist Finder (request)
- XML (request)
- JSON Asserts (request)
- JSON Tests (request)
- MusicBrainz (dir)
Il Tab “Tests” si trova nelle impostazioni di ogni collection/directory, alle quali si accede attraverso l’icona identificata dai 3 punti (si accende appoggiando il mouse al nodo dell’albero):
Questa possibilità che ci viene offerta è semplicemente fantastica (non trovate?), in quanto ci permette di riutilizzare il codice di quei Test comuni a più Request.
Ad esempio, possiamo definire che, per essere valide, tutte le request della nostra collection debbano rispondere entro i 500 millisecondi. Per implementare questo check, dobbiamo inserire le seguenti righe di codice nell’area “Tests” dei setting di collection:
test("[Collection] ResponseTime max is 500 ms", function() {
expect(res.getStatus()).to.be.lte(500);
});
Code language: JavaScript (javascript)
Potremmo anche voler verificare che le request di una specifica directory, essendo tutte GET, abbiano successo, restituendo lo status 200 (OK). Quindi, nell’area “Test” dei setting di directory andiamo ad inserire quanto segue:
test("[MusicBrainz] Response Status is OK (200)", function() {
expect(res.getStatus()).to.equal(200);
});
Code language: JavaScript (javascript)
Eseguendo una qualsiasi delle nostre request potremo verificare che, tra i risultati dei test ci sono anche quelli comuni per collection e directory (che abbiamo volutamente identificato):
Non è “meraviglioso”? (semicit.)
Test – Level Up
Fino ad ora abbiamo verificato la bontà delle risposte in formato JSON. Cosa potremmo fare nel sempre piu raro caso in cui il formato della risposta sia differente?
MusicBrainz ci offre la possibilità di ottenere gli stessi dati anche in formato XML cambiando un solo parametro della request:
Questa volta, in virtù del nuovo valore del parametro fmt otteniamo il seguente output:
<?xml version="1.0" encoding="UTF-8"?>
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
<artist id="084308bd-1654-436f-ba03-df6697104e19"
type="Group" type-id="e431f5f6-b5d2-343d-8b36-72607fffb74b">
<name>Green Day</name>
<sort-name>Green Day</sort-name>
<isni-list>
<isni>0000000122711282</isni>
</isni-list>
<country>US</country>
<area id="489ce91b-6658-3307-9877-795b68554c98">
<name>United States</name>
<sort-name>United States</sort-name>
<iso-3166-1-code-list>
<iso-3166-1-code>US</iso-3166-1-code>
</iso-3166-1-code-list>
</area>
<begin-area id="f9719692-c39a-4d9c-bf8c-dd4035b09534">
<name>Berkeley</name>
<sort-name>Berkeley</sort-name>
</begin-area>
<life-span>
<begin>1989</begin>
</life-span>
</artist>
</metadata>
Code language: HTML, XML (xml)
Per Bruno, questo output è una banalissima stringa che possiamo verificare con qualche assertion:
Expression | Operator | Value | Description |
res.body | startsWith | <?xml | La risposta inizia con “<?xml” |
res.body | contains | <metadata | La risposta contiene la stringa: “<metadata” |
res.body | contains | </metadata> | La risposta contiene la stringa: “</metadata>” |
res.body | contains | <name>Green Day</name> | La risposta contiene la stringa:“<name>Green Day</name>” |
Eseguendo la request otteniamo, nel tab “Test”, i seguenti risultati, ivi compresi, non dimentichiamoli, quelli dei test che abbiamo inserito a livello di directory e collection:
Ovviamente, questi sono controlli molto blandi che lasciano il tempo che trovano: abbiamo semplicemente chiesto a Bruno di verificare che una stringa contenga effettivamente delle sottostringhe.
Proviamo a fare il salto di qualità e trattiamo l’XML tale (e non come una stringa).
Iniziamo con l’aggiungere alla nostra directory principale il file package.json che ci permetterà di gestire il caricamento dei moduli npm aggiuntivi (necessari quando non bastano quelli “inbuilt” di Bruno). Il contenuto del file dovrebbe essere simile al seguente:
{
"name": "brunosample",
"version": "1.0.0",
"main": "index.js"
}
Code language: JSON / JSON with Comments (json)
A questo punto, da terminale, possiamo installare – nel contesto della nostra collection – i package che useremo per la gestione dell’XML:
npm install xmldom xpath
Il file package.json avrà guadagnato nuove dipendenze:
{
"name": "brunosample",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"xmldom": "^0.6.0",
"xpath": "^0.0.34"
}
}
Code language: JSON / JSON with Comments (json)
Perfetto, a questo punto possiamo iniziare ad implementare i test per verificare l’xml in risposta.
Spostiamoci nell’area Test della request ed iniziamo ad inserire le prime righe di codice comuni a tutti i test che andremo ad implementare:
const xpath = require('xpath');
const dom = require('xmldom').DOMParser;
const doc = new dom().parseFromString(res.body, 'text/xml');
Code language: JavaScript (javascript)
Ora che nella variabile doc abbiamo l’oggetto XML che rappresenta la nostra response, possiamo navigarla con xPath, per verificarne i dai sfruttando Chai.
Ad esempio, se volessimo verificare che il tag name contenga effettivamente la stringa “Green Day”, potremmo utilizzare quanto segue:
test("Expect Name is Green Day", function() {
const _query = "//*[local-name()='artist']/*[local-name()='name']/text()";
const _name = xpath.select(_query, doc);
expect(_name.toString()).to.equal("Green Day");
});
Code language: JavaScript (javascript)
Allo stesso modo possiamo verificare che la loro carriera sia effettivamente iniziata nel 1989:
test("Expect Begin is 1989", function() {
const _query = "//*[local-name()='artist']/*[local-name()='life-span']
/*[local-name()='begin']/text()";
const _begin = xpath.select(_query, doc);
expect(_begin.toString()).to.equal("1989");
});
Code language: PHP (php)
Ancora, verifichiamo che tutti gli elementi della lista isni (International Standard Name Identifier) inizino per “0” e finiscano con “2”:
E, per finire, verifichiamo che vi sia un solo elemento nella lista isin:
test("Expect isni has 1 element", function() {
const _query = "count(//*[local-name()='artist']/*[local-name()='isni-list']
/*[local-name()='isni'])";
const _isniCounter = xpath.select(_query, doc);
expect(_isniCounter).to.equal(1);
});
Code language: PHP (php)
test(“Expect isni has 1 element”, function() {
Bruno CLI
Ora che abbiamo impostato le nostre request, farcite di assert e test per verificarne la bontà delle risposte, possiamo sfruttare la CLI offerta da Bruno per eseguirle massivamente, del resto eseguirle singolarmente dalla App di Bruno sarebbe quantomeno noioso !
Per installare la CLI dobbiamo rifarci a npm con il seguente comando:
npm install -g @usebruno/cli
Da questo momento abbiamo il commando bru a nostra disposizione. Per eseguire tutte le request della directory MusicBrainz dobbiamo, nel terminale, spostarci nella directory principale della Collection ed eseguire:
bru run MusicBrainz -r
L’output che otteniamo è il seguente:
Running Folder Recursively
MusicBrainz/Artist Finder (200 OK) – 185 ms
✓ assert: res.body.artists: length 5
✓ [Collection] ResponseTime max is 500 ms
MusicBrainz/XML Tests (200 OK) – 203 ms
✓ assert: res.body: startsWith <?xml
✓ assert: res.body: contains <metadata
✓ assert: res.body: contains </metadata>
✓ assert: res.body: contains <name>Green Day</name>
✓ [Collection] ResponseTime max is 500 ms
✓ Expect Name is Green Day
✓ Expect Begin is 1989
✓ Expect every isni starts with 0 and ends with 2
✓ Expect isni has 1 element
MusicBrainz/JSON Asserts (200 OK) – 93 ms
✓ assert: res.status: eq 200
✓ assert: res.status: neq 400
✓ assert: res.responseTime: lte 500
✓ assert: res.body.name: eq Green Day
✓ assert: res.body.name: startsWith Green
✓ assert: res.body.name: endsWith Day
✓ assert: res.body.country: eq US
✓ assert: res.body.country: in [“US”,”UK”]
✓ assert: res.body.country: notIn [“IT”,”FR”,”ES”]
✓ assert: res.body.isnis: length 1
✓ assert: res.body.country: length 2
✕ assert: res.body.life-span.begin: eq “1989”
Cannot read properties of undefined (reading ‘begin’)
✓ assert: res.body[‘life-span’].begin: eq “1989”
✓ assert: res.body[‘begin-area’][‘sort-name’]: eq Berkeley
✓ assert: res.body.name: contains Day
✓ assert: res.body.name: notContains Sfera
✓ assert: res.body.area[‘iso-3166-1-codes’]: contains US
✓ assert: res.body.area[‘iso-3166-1-codes’]: notContains IT
✓ assert: res.responseTime: between 30,500
✓ assert: res.body.isnis.length: between 1,3
✓ assert: res.body.name.length: between 5,10
✓ assert: res.body.ipis: isEmpty
✓ assert: res.body.disambiguation: isEmpty
✓ assert: res.body.gender: isNull
✓ assert: res.body.notexists: isUndefined
✓ assert: res.body.gender: isDefined
✓ assert: res.body[‘life-span’].ended: isFalsy
✓ assert: res.body: isJson
✓ assert: res.body.area: isJson
✓ assert: res.body[‘life-span’].ended: isBoolean
✓ assert: res.responseTime: isNumber
✓ assert: res.body.type: isString
✓ assert: res.body.country: matches /^U[S|K]$/
✓ [Collection] ResponseTime max is 500 ms
✓ Response Status should be 200
MusicBrainz/JSON Tests (200 OK) – 124 ms
✓ [Collection] ResponseTime max is 500 ms
✓ [Using Expect] Response Status should be 200
✓ [Using Assert] Response Status should be 200
✓ [Using Expect] Response Time should be between 30 and 500
✓ [Using Expect] Career should start in 1989
✓ [Using Assert] Career should start in 1989
✓ [Using Expect] All Items in isnis should start with 0 and end with 2
Requests: 4 passed, 4 total
Tests: 15 passed, 15 total
Assertions: 37 passed, 1 failed, 38 total
Ran all requests – 605 ms
Alla versione attuale di bru (la 1.20), esistono due bug:
- I parametri a livello di URL non vengono presi in considerazione (l’output qui sopra è stato ottenuto da una mia versione locale fixata, per cui ho già aperto una PR).
- Gli script a livello di directory non vengono presi in considerazione da bru.
La CLI, nonostante questi problemi di gioventù, è comunque un importante strumento per il testing delle nostre API, integrabile anche nelle pipeline di CI/CD.
Conclusioni
Grazie per essere arrivati fino a qui!!! Spero di avervi fornito le basi per implementare test e assertion in Bruno e, soprattutto, di avervi trasmesso la voglia di sperimentare.
Bruno è relativamente giovane, è facile incappare in qualche bug (ne abbiamo visti giusto un paio nell’ultima sezione dedicata alla CLI): sappiate che sia le Pull Request che le segnalazioni di Bug o Features sul repository di Bruno (https://github.com/usebruno/bruno) sono sempre gradite. A tal proposito, la stesura di questo articolo mi ha dato l’opportunità di inviarne un paio… Partecipare ad un progetto in cui si crede è sempre fonte di soddisfazione.
Se volete scaricare le sopraccitate request e tutti i test/assert con cui le abbiamo “farcite”, trovate il repository qui:
https://github.com/fgrande/brunosample
È un repository dedicato alle sperimentazioni relative a Bruno, che cerco di tenere sempre aggiornato con le ultime novità. Fatelo vostro!