Come strutturare un set di classi che si occupano di API esterne per la massima testabilità?

0

Sto sviluppando una serie di classi progettate per comunicare con le API esterne e sto riscontrando problemi con la corretta strutturazione di tutto per un corretto accoppiamento e test dell'unità.

Attualmente, ogni API con cui dobbiamo parlare ha una classe distinta, che implementa un'interfaccia un po 'come questa:

public interface IApiIntegration
{
    Task<string> SearchApi (List<string> searchValues);
    Task<string> GetFromApi(string idToGet);
    Task<bool> PostToApi(PostObject api);
}

Ogni classe di API eredita da un abstract di base con implementa questa interfaccia. Quella classe contiene anche una serie di funzioni di supporto che sono rilevanti solo per gestire i dati provenienti da e verso Apis.

Al di sotto del metodo pubblico PostToApi di ogni classe ci sono anche un sacco di funzioni di supporto per costruire l'oggetto da pubblicare. Questi sono spesso abbastanza complicati e potrebbero davvero fare con i test. Tuttavia, sono specifici per la classe in questione e sono quindi privati.

All'interno di ogni funzione pubblica su IApiIntegration c'è anche, ovviamente, una chiamata ad un Api esterno. Ad esempio potrebbe sembrare qualcosa di simile:

public override async Task<string> GetFromApi(string id)
{
    string result = "";
    string path = $"{integration.RootUrl}items/{id}?username={integration.Username}&key={integration.Password}";

    // client is a static instance of HttpClient
    HttpResponseMessage response = await client.GetAsync(path);
    if (response.IsSuccessStatusCode)
    {
        result = await response.Content.ReadAsStringAsync();
    }

    return result;
}

Questo mi lascia due problemi:

1) È giusto che i metodi di supporto nella classe base e le singole classi siano aperti ai test unitari, ma anche che dovrebbero essere protetti / privati. Qualcosa, quindi, è chiaramente sbagliato nella struttura.

2) È ovviamente sbagliato testare le API esterne, quindi ho bisogno in qualche modo di bypassare o deridere queste dipendenze. Ma non è possibile in questa struttura.

Come posso refactoring e ristrutturare questo per garantire che tutto sia aperto per i test unitari?

    
posta Matt Thrower 03.12.2018 - 15:13
fonte

3 risposte

1

... there are also a bunch of helper functions to build the object to be posted. These are often quite complicated, and could really do with testing.

Costruire un oggetto, specialmente uno complicato, è una logica che non dovrebbe essere nascosta dall'imbrago di prova. Dovrebbe esistere una funzione pubblica in grado di creare e restituire l'oggetto. È effettivamente un costruttore e probabilmente dovrebbe essere una pura funzione (dipende solo dai parametri di input e non cambia alcun stato).

Allo stesso modo, elaborare i dati API è una logica che non dovrebbe essere nascosta. Ancora una volta, una funzione pubblicamente disponibile che prende il tipo di dati che ci si aspetta che l'API emetta e restituisca oggetti dopo l'elaborazione dei dati è il modo migliore per andare.

Quindi la tua funzione getFromAPI chiamerà semplicemente l'object builder per fare l'input corretto e poi chiamerà il processore di dati per creare gli oggetti corretti. Questa funzione finirà per essere così semplice che non dovresti sentire la necessità di testarlo (IMHO).

Il punto chiave è che dovresti non sentire la necessità di testare le funzioni dell'API. Se ti senti particolarmente cauto, potresti voler verificare che vengano chiamati usando un test double / mock, ma questo è tutto. Tutto quello che tu devi testare è che stai creando oggetti validi da passare nell'API e che quando l'API restituisce dati validi, stai elaborando i dati correttamente.

Alcuni buoni video sull'argomento:

risposta data 22.12.2018 - 23:08
fonte
1

Se hai un'API esterna che non puoi controllare, ovvero non ha un'istanza di prova con dati statici, la soluzione migliore è registrare e riprodurre il traffico di rete da chiamate reali.

Una volta acquisiti questi dati, puoi configurare un server simulato che ascolti le richieste in entrata, le corrisponda alle richieste note e restituisca la risposta corrispondente.

Ora puoi testare il tuo client contro questo server senza dover esporre i metodi interni

    
risposta data 03.12.2018 - 15:28
fonte
1

Ho una piccola applicazione in Ruby che risolve un problema simile usando il modello di progettazione di porte e adattatori (esagonali) di Alistair Cockburn. L'applicazione invia avvisi ai singoli destinatari sulla loro piattaforma di comunicazione preferita (ad esempio SMS, e-mail, Twitter, ecc.). Ecco come appare (in Ruby / Rails):

class Recipient < ActiveModel
  # Recipient model has two attributes:
  #  - channel: one of sms, email, twitter, messenger, whatsapp
  #  - address: one of phone number, email address, twitter handle, etc.

  NOTIFIER_CLASSES = {
    sms: SMSNotifier, 
    email: EmailNotifier,
    twitter: TwitterNotifier,
    messenger: FacebookMessengerNotifier,
    whatsapp: WhatsNotifier
  }.freeze

  def notify(message)
    notifier.notify(address, message)
  end

  private

  def notifier
    NOTIFIER_CLASSES[channel].new(address)
  end
end

Qui, cerco di creare un'istanza del notificatore corretto e di passargli gli argomenti corretti. Mi piace così (in RSpec):

describe Recipient do
  subject(:recipient) { described_class.new(recipient_attributes) }

  let(:recipient_attributes) { { channel: channel, address: address } }
  let(:channel) { 'some channel' }
  let(:address) { 'some address' }
  let(:message) { 'some message' }

  describe "#notify" do
    subject(:notify) { recipient.notify(message) }

    context 'when channel is sms' do
      let(:channel) { :sms }

      before do
        allow(SMSNotifier).to receive(:notify)
        notify
      end

      it 'uses the correct notifier with the correct attributes' do
        expect(SMSNotifier).to have_received(:notify).with(address, message)
      end
    end

    context 'when channel is email' do
      let(:channel) { :email }

      before do
        allow(EmailNotifier).to receive(:notify)
        notify
      end

      it 'uses the correct notifier with the correct attributes' do
        expect(EmailNotifier).to have_received(:notify).with(address, message)
      end
    end
  end
end

E così via ... (Nota: in realtà, questi test verrebbero prosciugati con esempi condivisi.)

Tutti i notificanti definiscono lo stesso metodo notify (digitazione anatra in Ruby, sebbene si voglia utilizzare un'interfaccia in un linguaggio tipizzato staticamente). Sembrano così:

class SMSNotifier
  FROM = '+18005551212'.freeze

  def notify(address, message)
    messages.create(from: FROM, to: address, body: message)
  end

  private

  def messages
    client.api.account.messages
  end      

  def client
    Twilio::REST::Client.new(ENV['ACCOUNT_SID'], ENV['AUTH_TOKEN'])
  end
end

class EmailNotifier
  SENDGRID_ENDPOINT = 'https://api.sendgrid.com/v3/mail/send'.freeze
  FROM = '[email protected]'.freeze

  def notify(address, message)
    RestClient.post(
      SENDGRID_ENDPOINT,
      {
        to: [{ email: address }],
        subject: 'Alert',
        content: [{ value: message, type: 'text/plain' }]
      },
      'Authorization' => "Bearer #{ENV['SENDGRID_API_KEY']}"
    )
  end
end

Come puoi vedere, gli interni sono abbastanza diversi per ciascun Notifier, a seconda dell'API sottostante. In questi casi, utilizzo i mock per assicurarmi di effettuare le chiamate API corrette con i parametri corretti. Confido che queste API siano completamente testate. Non ho bisogno di fare un round trip in un test unitario.

describe SMSNotifier do
  subject(:notifier) { described_class.new }

  describe "#notify" do
    subject(:notify) { notifier.notify(address, message) }

    let(:address) { '+12345678900' }
    let(:message) { 'some message' }

    let(:client) { instance_double(Twilio::REST::Client, api: api) }
    let(:api) { instance_double('api', account: account) }
    let(:account) { instance_double('account', messages: messages) }
    let(:messages) { instance_double('messages', create: true) }

    before do
      allow(Twilio::REST::Client).to receive(:new).and_return(client)
      notify
    end

    it 'uses the correct notifier with the correct attributes' do
      expect(Twilio::REST::Client).to have_received(:new).with(account_sid, auth_token)
    end

    it 'makes the correct api call with the correct attributes' do
      expect(messages).to have_received(:create).with(address, message)
    end
  end
end

describe EmailNotifier do
  subject(:notifier) { described_class.new }

  describe "#notify" do
    subject(:notify) { notifier.notify(address, message) }

    let(:address) { '[email protected]' }
    let(:message) { 'some message' }

    let(:email_attributes) do
      {
        to: [{ email: address }],
        subject: 'Alert',
        content: [{ type: 'text/plain', value: message }]
      }
    end
    let(:headers) { { 'Authorization' => "Bearer sendgrid_api_key" } }

    before do
      allow(RestClient).to receive(:post)
      notify
    end

    it 'uses the correct notifier with the correct attributes' do
      expect(RestClient).to have_received(:post).with(
        'https://api.sendgrid.com/v3/mail/send',
        email_attributes,
        headers
      )
    end
  end
end

Quindi, il modello si riduce a porte e adattatori, dove i Notificatori sono le porte e le API Twilio e SendGrid sono adattatori per i servizi specifici.

    
risposta data 22.12.2018 - 07:52
fonte

Leggi altre domande sui tag