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:
- logica applicativa
- interazione Presenter -> View
- interazione View -> Presenter
- 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.
[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-