Come / se testare combinazioni di base di funzioni già testate

3

Ci scusiamo per la domanda prolissa. Spero che la domanda e le sue potenziali risposte possano servire come esempio utile per gli sviluppatori che si stanno chiedendo dove impostare la barra per testare combinazioni abbastanza semplici di funzioni già testate.

Considerare il seguente modello semplice per un ordine (la definizione dei tipi ausiliari è omessa per brevità):

type Order =
    {OrderDate: DateTime
     CustomerId: CustomerId;
     SalesPersonId: SalesPersonId;
     OrderLines: OrderLine list}

Diciamo che vogliamo aggregare i dati di tutti gli ordini in un certo intervallo di tempo. Inoltre, diciamo che potremmo voler includere solo i dati degli ordini che corrispondono a un determinato ID cliente e / o un determinato ID di una persona di vendita. Questo potrebbe essere implementato come una serie di funzioni semplici con firma Order -> Order option (dopo l'applicazione parziale di qualunque sia il parametro del filtro), quindi combinate usando Option.bind e infine applicate a un elenco di ordini usando List.choose . Ad esempio:

type OrderFilter =
    {StartDate: DateTime;
     EndDate: DateTime;
     CustomerId: CustomerId option;
     SalesPersonId: SalesPersonId option}

// DateTime -> Order -> Order option
let filterStartDate startDate order = 
    if order.OrderDate >= startDate then Some order else None

// DateTime -> Order -> Order option
let filterEndDate endDate order = 
    if order.OrderDate <= endDate then Some order else None

// CustomerId -> Order -> Order option
let filterCustomerId custId order = 
    match custId with
    | None | Some id when order.CustomerId = id -> Some order
    | _ -> None

// SalesPersonId -> Order -> Order option
let filterSalesPersonId spId order = 
    match spId with
    | None | Some id when order.SalesPersonId = id -> Some order
    | _ -> None

// OrderFilter -> Order -> Order option
let applyOrderFilter filter order =
    let (>=>) f1 f2 = f1 >> (Option.bind f2)
    let combinedFilter =
        filterStartDate filter.StartDate
        >=> filterEndDate filter.EndDate
        >=> filterCustomerId filter.CustomerId
        >=> filterSalesPersonId filter.SalesPersonId
    order |> combinedFilter

Il filtraggio viene quindi eseguito in questo modo da altrove nel codebase:

let filter = 
    {StartDate = ...;
     ...}

let orders = getOrders() |> List.choose (applyOrderFilter filter)
...

Ora, alla domanda in questione: le singole funzioni del filtro sono (e sono state progettate per essere) facili da testare. Secondo la mia opinione piuttosto inesperta, la controllabilità di applyOrderFilter è quindi discutibile: tutte le sue parti centrali costitutive sono accuratamente testate; si può facilmente verificare (IMHO) semplicemente osservando che funziona correttamente; e la composizione delle funzioni in questo modo sembra essere principalmente un caso di "se compila, funziona".

L'unica eccezione molto importante è l'elenco effettivo di funzioni che componi, in cui non riceverai avvisi se dimentichi di includere una delle funzioni di filtro. (L'applicazione parziale è un'altra fonte di potenziali errori - in questo caso, è possibile cambiare gli argomenti della data di inizio / fine. Poiché il filtraggio dei dati è corretto per l'azienda e non facilmente individuabile dall'utente (che vede solo i dati aggregati) , Sono favorevole ad avere una sorta di test automatico per applyOrderFilter .

Ora, il test unitario applyOrderFilter sembra come se dovesse duplicare tutti i test per le singole funzioni del filtro e causare un'esplosione combinatoria di casi di test. Sarebbe anche eccessivo, perché l'unica cosa rilevante da testare sembra essere solo la combinazione dei filtri, per proteggersi dalle regressioni (l'eliminazione accidentale di una linea nella composizione del filtro sarebbe davvero pessima).

Dove ho bisogno di aiuto è nel capire quanto segue:

  1. È necessario testare la composizione di tali funzioni? Ho fornito le mie ragioni sopra, ma potrei sbagliare sul lato di troppi test e non vedere i contro-argomenti (ad esempio "fare in modo che il tipo di modifica sia così evidentemente sbagliato sarebbe imperdonabile" - ad un certo punto non è possibile prendere in considerazione più stupidità / controllo da parte degli sviluppatori).

  2. In che modo la composizione del filtro può essere testata facilmente e in modo affidabile senza dover testare la logica del filtro vero e proprio? Mi sembra che sarebbe necessario un refactoring per rendere possibile tutto ciò, ma io m bloccato a capire come progettare questo per un facile test.

posta cmeeren 18.09.2017 - 14:11
fonte

2 risposte

3

Kent Beck :

I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence (I suspect this level of confidence is high compared to industry standards, but that could just be hubris). If I don't typically make a kind of mistake (like setting the wrong variables in a constructor), I don't test for it. I do tend to make sense of test errors, so I'm extra careful when I have logic with complicated conditionals. When coding on a team, I modify my strategy to carefully test code that we, collectively, tend to get wrong.

Il caso d'uso principale per i test unitari è quello di supportare il refactoring sicuro; quindi il tipo di euristica a cui penserei qui è "quanto è probabile che si rompa questo codice durante il refactoring?". Se il sistema di tipi ti proteggerà dagli errori che fai comunemente, probabilmente non otterrai molto valore dalla scrittura di test aggiuntivi.

In che modo la composizione del filtro può essere testata facilmente e in modo affidabile senza dover testare la logica del filtro vero e proprio?

Fornendo implementazioni di stub al posto dei filtri effettivi? che il tuo attuale design non sembra supportare. Vale a dire, sembra che manchi un metodo simile a

let compose dateConstraint customerConstraint salesConstraint = ...

che potresti invocare tramite

compose stub stub stub

Probabilmente dovresti rivedere il blog di Mark Seeman , che scrive un bel po 'sulla gestione delle dipendenze.

Still, it kinda feels like this only solves part of the problem and moves the rest up one layer - since now the caller (or an intermediary) will have to pass the correct functions. Did I perhaps misunderstand something?

Si tratta principalmente di spostare le cose.

let processOrder order =
    if   validateOrder order 
    then acceptOrder   order 
    else declineOrder  order

vs

let defineOrderProcess v a d order =
    if   v order
    then a order
    else d order

let processOrder order = defineOrderProcess validateOrder acceptOrder declineOrder order

Vale a dire, se sei d'accordo che il primo approccio "ovviamente non ha carenze", allora lo lasci in pace. Altrimenti, introduci due passaggi, crei una composizione verificabile (defineOrderProcess) insieme a una specializzazione invocata della composizione (che è troppo semplice per fallire).

Nel tuo esempio specifico, parte del problema che vedo è che il tuo problema non è scomposto in modo appropriato

let applyOrderFilter filter order =
    let (>=>) f1 f2 = f1 >> (Option.bind f2)
    let combinedFilter =
        filterStartDate filter.StartDate
        >=> filterEndDate filter.EndDate
        >=> filterCustomerId filter.CustomerId
        >=> filterSalesPersonId filter.SalesPersonId
    order |> combinedFilter

Questa funzione ha una serie di diverse responsabilità; sta penetrando all'interno del filtro e sta definendo un elenco di funzioni di filtro e sta definendo una piega su quell'elenco di funzioni.

Quindi dovresti iniziare a pensare all'elenco delle funzioni di filtro e a come sarebbe l'implementazione se quell'elenco fosse esplicito

[ filter -> filterStartDate     filter.StartDate
; filter -> filterEndDate       filter.EndDate
; filter -> filterCustomerId    filter.CustomerId
; filter -> filterSalesPersonId filter.SalesPersonId
; ... ]

Quindi avresti tre pezzi: i vincoli individuali (che hanno test unitari), la piega (troppo noioso per fallire una volta che il sistema dei tipi lo benedice), e l'elenco esplicito di vincoli - che con maggiori investimenti nella denominazione probabilmente raggiungerebbe il punto di essere ovviamente corretto.

[ dateConstraint
; customerConstraint
; salesConstraint
; ... ]

In altre parole, dovresti essere imbattuto in questa implementazione fino a quando l'elenco non cade, e quindi decidere se è necessario scrivere test unitari per gli elementi di un elenco.

Punchline: con l'idea di questo tentativo di refactoring in mente, come ti senti a provare questo refactoring con la tua attuale copertura di test ? Se ti senti esitante, pensando che vorrai avere più test in atto prima di iniziare il refactoring, allora è un grande indizio che sì, hai bisogno di "testare la composizione di queste funzioni".

    
risposta data 18.09.2017 - 16:14
fonte
4

Come hai affermato, è facile dimenticare una funzione di filtro nell'elenco e questo errore sarebbe difficile da rilevare nei test successivi. Ciò significa che dovresti davvero scrivere test per applyOrderFilter .

I test per applyOrderFilter dovrebbero non duplicare tutti i test per i singoli filtri, ma dovrebbero testare la funzionalità che applyOrderFilter aggiunge in aggiunta alle singole funzioni di filtro.
Questo può essere verificato utilizzando dati di test appositamente predisposti in modo tale che in ciascun caso di test si sappia che uno specifico filtro potrebbe causare l'inclusione o l'esclusione di un elemento di input dal set di risultati. In questo modo, sai che la funzione filtro viene utilizzata da applyOrderFilter .

    
risposta data 18.09.2017 - 14:53
fonte

Leggi altre domande sui tag