papo we(b)log

software engineering slave!
posts - 29, comments - 49, trackbacks - 26

Esempio (semplice) di UI Test-Driven con MVP

nonostante abbia ormai concluso il lavoro (di tesi) su cui stavo lavorando, in questi giorni ho lo stesso proseguito un po' nello sviluppo dell'applicazione che ho usato come caso di studio, un piccolo generatore di classi. in particolare, ho iniziato ad occuparmi della scrittura, test-driven, della UI WinForm.

per fare questo, mi sono affidato al pattern Model-View-Presenter (o qualsiasi sia il nome con cui è stato ribattezzato!), una variante del Model-View-Controller in cui la vista risulta "passiva". cosa questo significhi non è immediato da capire, e non sono serviti a molto nemmeno i vari articoli che ho trovato girando in rete. il problema fondamentale, per me, è stato capire (come penso si dovrebbe fare adottando un qualsiasi pattern) il ruolo dei singoli elementi: vista, presenter e modello. analizziamo un esempio, preso da quanto ho fatto per il mio progetto.

prima di tutto partiamo definendo un test di accettazione che ci dia una direzione da seguire: voglio realizzare un applicativo che, una volta avviato, dia la possibilità di selezionare un file esistente (un .cs) e indicare il tipo di classe che desidera generare (al momento, classi concrete o classi stub). [tralascio per ora il dominio di riferimento, il resto del post è lo stesso comprensibile]. per avviare la generazione è disponibile un qualche pulsante o simile, che rimane disabilitato finchè non sono stati selezionati entrambi i valori.

una possibile tabella di FitNesse è la seguente:

!| fit.ActionFixture |
| start | MainFormFixture |
| check | Run Button 
is | disabled |
| enter | File TextBox | "IGreetings.cs" |
| check | Run Button 
is | disabled |
| enter | Target ComboBox | "stub" |
| check | Run Button 
is | enabled |

sebbene in tabella si parli di "textbox", "combobox" e "button", in realtà quello che stiamo specificando è una regola applicativa (per ora non ho specificato nessuna particolare regola di validazione; sono presenti in altre tabelle, dedicate alla logica di validazione). si tratta quindi di una candidata per diventare responsabilità del presenter.

la scrittura della fixture per eseguire questo test di accettazione quindi richiede:

  • la presenza di un presenter a cui inoltrare le richieste
  • una metodo per conoscere lo stato del bottone Run
  • una proprietà per impostare il testo del textbox File
  • una proprietà per selezionare la voce del combobox Target

using fit;

public class MainFormFixture : Fixture
{    
    
private MainPresenter _presenter = new MainPresenter( new StubMainView() );
    
    
public string FileTextBox
    {
        
set{ _presenter.SelectedInterface = value; }
    }
    
    
public string TargetComboBox
    {
        
set { _presenter.SelectedTarget = value; }
    }
    
    
public string RunButtonIs()
    {
        
return BoolToEnabled( _presenter.CanRun() );
    }
    
    
private string BoolToEnabled(bool item)
    {
        
return (item) ? "enabled" : "disabled";
    }
}

nient'altro, oltre al metodo helper per la conversione tra bool e stringa. come si può notare, così facendo abbiamo individuato "l'interfaccia" richiesta al presenter: due proprietà per impostare i valori stringa (SelectedInterface e SelectedTarget), un metodo per conoscerne "la validità" (CanRun).

la cosa interessante è che non ho vincolato, ancora, l'applicazione ad essere di tipo WinForms: sarà infatti la vista ad occuparsene. questo è un punto fondamentale, almeno per me lo è stato, ovvero scoprire cosa realmente voglio specificare del comportamento "visuale", e cosa quindi andrò a testare con ulteriori test di unità.

per ora mi basta istanziare il presenter con una vista "fittizia", StubMainView. adesso mi occupo della scrittura a livello più basso del comportamento atteso, passo cioè a scrivere i test di unità. l'importante, nuovamente, è capire:

  • come avvengano le interazioni tra gli elementi che compongono il pattern (in particolare View-Presenter)
  • dove definire le aspettative sui comportamenti attesi. scrivo asserzioni rigurardo il presenter? e riguardo la view?

quello che io sono riuscito a capire, grazie ad un esempio preso dall'ultimo capitolo del nuovo libro di "Uncle" Bob Martin (Agile Principles, Patterns, and Practices in C#), è che esistono almeno quattro "scope" che vale la pena analizzare e nei quali ricercare il comportamento dell'applicazione, ovvero:

  1. logica applicativa
  2. interazione Presenter -> View
  3. interazione View -> Presenter
  4. logica UI

così facendo, mi viene comodo riunire nei test di unità del presenter i primi due punti, mentre il terzo e il quarto punto li "confino" negli unit test della vista, se necessario. riprendo ora l'esempio.

per il primo punto, la logica applicativa, in sostanza stiamo parlando di quanto espresso dalla tabella del test di accettazione. un test di unità quindi può riprendere lo stesso esempio, e dire:

[TestFixture]
public class MainPresenterFixture ...
    
    [Test]
    
public void CanRunOnlyWhenBothInterfaceAndTargetAreSet()
    {
        
//expect
        
_mocks.ReplayAll();
        
        
//assert
        
Assert.IsFalse( _fixture.CanRun(), "can run on init" );
        
        
//operate
        
_fixture.SelectedInterface = "a file";
        
        
//assert
        
Assert.IsFalse( _fixture.CanRun(), "can run with just interface set" );
        
        
//operate
        
_fixture.SelectedTarget = "stub";
        
        
//assert
        
Assert.IsTrue( _fixture.CanRun(), "cannot run with both items set" );
        
        
//operate
        
_fixture.SelectedInterface = string.Empty;
        
        
//assert
        
Assert.IsFalse( _fixture.CanRun(), "can run with empty interface");
    }

[no, non è una ripezione rifare lo stesso esempio: K.Beck lo chiama il "double check", e è assicuro che può tornare utile. ad esempio, l'ultima asserzione l'ho aggiunto per ultima, in seguito, dopo che mi ero accorto che il bottone non sempre veniva aggiornato: condizione che mancava nei test di accettazione, sebbene fosse però lo stesso esempio.]

per il secondo punto invece devo pensare a quali sono le interazioni che il presenter ha "verso" la vista: inizialmente questo non mi era molto chiaro, ma alla fine (sempre grazie a lo "zio" Bob) ho focalizzato le idee, e nel nostro caso ad esempio si tratta di dire che quando vengono inseriti dei valori, il bottone di Run deve essere aggiornato in conseguenza alla validità stessa dei dati. quindi:

[Test]
public void ViewUpdated()
{
    
//expect
    
using(_mocks.Ordered())
    {
        _mockView.RunEnabled = 
false;
        LastCall
            .Repeat.Once();
        
        _mockView.RunEnabled = 
true;
        LastCall
            .Repeat.Once();
    }
    
    _mocks.ReplayAll();
    
    
//operate
    
_fixture.SelectedInterface = "a file";
    _fixture.SelectedTarget = "stub";
}

in realtà gli esempi sul libro di Martin usano un test di unità basato sullo stato, e verificano che una vista "mock" sia stata impostata in un suo campo booleano con il valore false e true, di seguito. a me piace poco, e quindi ho usato il test delle interazioni: mi basta verificare che, in sequenza, alla vista sia richiesto di aggiornare il valore della proprietà RunEnabled (che diventa così il primo membro dell'interfaccia IMainView). per completezza, ecco il SetUp e il TearDown, usando come mio solito Rhino.Mocks (tralascio la definizione dei campi privati):

[SetUp]
public void SetUp()
{
    _mocks = 
new MockRepository();
    _mockView = _mocks.DynamicMock<IMainView>();
    
    _fixture = 
new MainPresenter(_mockView);
}

[TearDown]
public void TearDown()
{
    
if(_mocks != null)
    {
        _mocks.VerifyAll();
    }
}

un'ulteriore comportamento da specificare (e testare) è quello per il quale il combobox viene, all'avvio, popolato con i valori dei tipi di classe disponibili. si tratta di una responsabilità "a cavallo" tra la regola applicativa (quali sono i tipi) e l'interazione P->V (viene popolato il combobox). ciò nonostante, è compito del presenter, e non della vista: quindi invece di testare che la vista (es: la Windows.Form) sia impostata correttamente, specifico che il presenter "avverta" la vista in modo opportuno. introduco una nuova proprietà, un array di stringe chiamato AvailableTargets:

[Test]
public void InitViewWithTargets()
{            
    
//expect            
    
_mockView.AvailableTargets = null;
    LastCall
        .Constraints( List.Equal(
new string[] { "class", "stub" }) );
    
    _mocks.ReplayAll();
    
    
//operate
    
_fixture.InitView();
}

se volessi separare la regola applicativa, mi basterebbe usare LastCall.IgnoreArguments e porre il controllo dei valori in un test separato. per ora mi va bene così. rimangono da vedere ora i test di unità relativi al terzo e quarto punto: la vista.

in questo caso, più che un test di accettazione, la "guida" vera e propria può essere un disegno bozza di come il cliente si aspetta la UI. nel mio caso, ho disegnato un piccolo form con un pulsante di "Browse" per selezionare un file, il cui nome viene messo nel textbox (di cui abbiamo parlato finora). inoltre, c'è il combobox e un pulsante "Run" per avviare la generazione. questo mi serve per aprire il mio IDE e creare la UI, una Windows.Form a cui faccio, ulteriormente, implementare l'interfaccia IMainView.

WinForm UI

[tralasciamo per ora l'analisi di usabilità e tutto il resto, mi focalizzo sulla realizzazione della vista cercando di capire quale spazio abbiano i test di unità in tutto questo].

partiamo dal terzo punto, le interazioni View->Presenter. cosa possiamo specificare? in sostanza, la vista (detta anche "vista passiva" nel MVP) non deve far altro che comunicare al presenter ogni cambiamento nei dati inseriti dall'utente, e quindi possiamo scrivere:

[TestFixture]
public class MainFormFixture ...
    
    [Test]
    
public void UpdatePresenter()
    {
        
//init
        
ClearAndInitTargets();
        
        
//expect
        
using(_mocks.Ordered())
        {
            _mockPresenter.SelectedInterface = "a file";
            LastCall
                .Repeat.Once();
            
            _mockPresenter.SelectedTarget = "mock";
            LastCall
                .Repeat.Once();
        }
        _mocks.ReplayAll();
        
        
//operate
        
_fixture.FileTextBox.Text = "a file";
        _fixture.TargetComboBox.Text = "mock";
    }
    
    
private void ClearAndInitTargets()
    {
        _fixture.TargetComboBox.Items.Clear();
        _fixture.TargetComboBox.Items.Add("stub");
        _fixture.TargetComboBox.Items.Add("mock");
    }

quello che stiamo verificando è che, se l'utente cambia in successione prima il nome del file e poi il tipo di classe, il presenter sia notificato di questi due cambiamenti, nella corretta successione. questa volta non stiamo parlando di una vista "fittizia", nè in termini di interfaccia, quindi FileTextBox etc. sono elementi reali, che ho inserito nella Form usando l'editor grafico del mio IDE. ecco cosa manca per l'avvio del test:

[SetUp]
public void SetUp()
{
    _mocks = 
new MockRepository();
    _mockPresenter = _mocks.DynamicMock<MainPresenter>();
    
    _fixture = 
new MainForm();
    _fixture.Presenter = _mockPresenter;
    
    
//_fixture.Show();
}

[Bob Martin consiglia di togliere il commento all'ultima riga, perchè altrimenti dice che "stranamente" alcuni comportamenti visuali non funzionano correttamente. per ora ho visto che tutto ciò che mi serve funziona, quindi se posso evitare di avere schermate che compaiono durante l'esecuzione dei test, tanto meglio!]

non rimane quindi altro che specificare l'ultimo punto, la logica UI. senza vincolare troppo l'implementazione, quello che mi sembra necessario specificare è lo stato della Form all'avvio e come la Form reagisca alla richiesta di abilitare l'avvio (ricordate, la proprietà RunEnabled, una delle due dichiarate per l'interfaccia IMainView). quindi:

[Test]
public void Init()
{            
    
//expect (not used, but required)
    
_mocks.ReplayAll();
    
    
//operate (simulate presenter after initialization)
    
_fixture.AvailableTargets = new string[] {"concrete", "stub", "mock"};
    
    
//assert: button (1), textbox (1) and combobox (2)
    
Assert.IsFalse( _fixture.RunButton.Enabled, "run enabled" );
    Assert.AreEqual( 
string.Empty, _fixture.FileTextBox.Text, "file textbox not empty" );
    Assert.AreEqual( 3, _fixture.TargetComboBox.Items.Count, "wrong items number" );
    Assert.AreEqual( "concrete", _fixture.TargetComboBox.Text, "combo not set" );
}

[Test]
public void RunEnabled_Set_RunButton_Enabled()
{
    
//expect (nopt used, but required)
    
_mocks.ReplayAll();
    
    
//assert
    
Assert.IsFalse(_fixture.RunButton.Enabled, "run button enabled");
    
    
//operate (simulate presenter)
    
_fixture.RunEnabled = true;
    
    
//assert
    
Assert.IsTrue(_fixture.RunButton.Enabled, "run button not enabled");
}

forse mi sono dilungato un po' troppo, ma volevo mettere a fuoco alcuni concetti sui quali ho lavorato tra ieri e oggi. manca da vedere l'implementazione, ma per ora mi interessa di meno. in ogni caso, la parte più importante riguarda la logica applicativa del presenter, che utilizza una classe Command e la classe ParamCommands, e l'interazione tra vista e presenter, che utilizza invece il modello "a eventi" delle WinForms per avvisare il presenter delle modifiche, ad esempio:

//"file" textbox handler
private void TxtFileTextChanged(object sender, System.EventArgs e)
{
    _presenter.SelectedInterface = txtFile.Text;
}

//"target" combobox handler
private void CmbTargetSelectedIndexChanged(object sender, System.EventArgs e)
{
    _presenter.SelectedTarget = cmbTarget.Text;
}

//"browse" button handler
void BtnBrowseClick(object sender, EventArgs e)
{
    
if(dlgOpenFile.ShowDialog() == DialogResult.OK)
    {
        txtFile.Text = dlgOpenFile.FileName;
    }
}

spero di poter tornare su questi argomenti, con una conoscenza più approfondita di quella che ho ora, visto che si tratta di una parte del Test-Driven Development sicuramente interessante, e direi critica.

alla prossima.
-papo-

Print | posted on lunedì 29 gennaio 2007 17:08 |

Powered by:
Powered By Subtext Powered By ASP.NET