The Code Gorilla

Friday 22 March 2013

Getting started with SpecFlow (Part 2)

This is part 2, if you've not read part 1 please do. Download source code

Quick recap


We have our solution, Dentist with one project Dentist.Specs with a single feature file called BookAnAppointment.feature.
Its now time to generate some code, if you have previously generated the steps then select the step file you created and delete it.

Part 2 - Time for code


Generating Steps


Open the feature file, right click and select Generate Step Definitions, click Generate, navigate to the project directory (unfortunately the tool does not do this by default), set the file name for the steps as BookAnAppointmentSteps.cs and then click Save.



You should notice a colour change in the syntax highlighting in your feature file, this is shown to indicate that steps have been created in code. If you add new steps in the feature file you can generate the additional steps in code by right clicking on the feature file and select Generate Step Definitions.

A quick look at the step code just generated will show you:

using System;
using TechTalk.SpecFlow;

namespace Dentist.Specs
{
    [Binding]
    public class BookAnAppointmentSteps
    {
        [Given(@"The Dentists")]
        public void GivenTheDentists(Table table)
        {
            ScenarioContext.Current.Pending();
        }
        
        [Given(@"The Registered Patients")]
        public void GivenTheRegisteredPatients(Table table)
        {
            ScenarioContext.Current.Pending();
        }
        
        [Given(@"The Current Appointments")]
        public void GivenTheCurrentAppointments(Table table)
        {
            ScenarioContext.Current.Pending();
        }
        
        [Given(@"Appointment slots are (.*) minutes")]
        public void GivenAppointmentSlotsAreMinutes(int p0)
        {
            ScenarioContext.Current.Pending();
        }
      
        [When(@"An appointment request '(.*)' is made for patient '(.*)'")]
        public void WhenAnAppointmentRequestIsMadeForPatient(string p0, string p1)
        {
            ScenarioContext.Current.Pending();
        }
        
        [Then(@"There is a confirmation that the appointment '(.*)' is booked for the patient '(.*)' with the dentist '(.*)'")]
        public void ThenThereIsAConfirmationThatTheAppointmentIsBookedForThePatientWithTheDentist(string p0, string p1, string p2)
        {
            ScenarioContext.Current.Pending();
        }
        
        [Then(@"The appointment '(.*)' is not available for dentist '(.*)'")]
        public void ThenTheAppointmentIsNotAvailableForDentist(string p0, string p1)
        {
            ScenarioContext.Current.Pending();
        }
        
        [Then(@"The appointment request fails")]
        public void ThenTheAppointmentRequestFails()
        {
            ScenarioContext.Current.Pending();
        }
        
        [Then(@"Alternative appointments '(.*)' for dentist '(.*)' for that day are given")]
        public void ThenAlternativeAppointmentsForDentistForThatDayAreGiven(string p0, string p1)
        {
            ScenarioContext.Current.Pending();
        }
    }
}

Take a bit of time to familiarize yourself with the code generated, specifically look at the attributes and see that the parameters are extracted via regular expression matching.

The Data Model


I'm going to skip ahead a bit and define a basic data model as identified by the feature file. Create a new Class Library project called DataModel, and paste the following code into it. Add a reference to the DataModel project from the Dentist.Specs project.

namespace DataModel
{
    public class DentistSurgeryModel
    {
        public DentistSurgeryModel()
        {
            Dentists = new List<Dentist>();
            Patients = new List<Patient>();
            Registrations = new List<Registration>();
            Appointments = new List<Appointment>();
            AppointmentSlotTime = 10; // default to 10 minutes
        }

        public List<Dentist> Dentists { get; set; }
        
        public List<Patient> Patients { get; set; }

        public List<Registration> Registrations { get; set; }

        public List<Appointment> Appointments { get; set; }
        
        public int AppointmentSlotTime { get; set; }
    }

    public class Dentist
    {
        public string Name { get; set; }
    }

    public class Patient
    {
        public string Name { get; set; }
    }

    public class Registration
    {
        public Patient Patient { get; set; }
        public Dentist Dentist { get; set; }
    }

    public class Appointment
    {
        public DateTime At { get; set; }
        public Patient Patient { get; set; }
        public Dentist Dentist { get; set; }
    }
}

Prepare the scenarios


With a data model in place we can start with defining the Given steps. Its at this point that I shall introduce two more SpecFlow attributes, BeforeScenario and AfterScenario which can be used on any methods in the binding and these are global for all scenarios as well (you can restrict this using StepScope Attribute or using tags in the feature file and specify them with the BeforeScenario & AfterScenario Attribute).

namespace Dentist.Specs
{
    [Binding]
    public class BookAnAppointmentSteps
    {
        DataModel.DentistSurgeryModel _model;
        const string _dateTimeFormat = @"d/M/y H:mm";

        [BeforeScenario]
        public void CreateDataModel()
        {
            _model = new DataModel.DentistSurgeryModel();
        }

        [AfterScenario]
        public void DisposeDataModel()
        {
            _model = null;
        }

I've added a _dateTimeFormat for use with converting the date time information from the feature file, which is passed in to the steps as strings.

Implement the Givens


Moving on we can now implement the Givens as we have a data model to populate.

[Given( @"The Dentists" )]
public void GivenTheDentists( Table table )
{
    foreach( var row in table.Rows )
    {
        DataModel.Dentist dentist = new DataModel.Dentist { Name = row["dentist"] };
        _model.Dentists.Add( dentist );
    }
}

[Given( @"The Registered Patients" )]
public void GivenTheRegisteredPatients( Table table )
{
    foreach( var row in table.Rows )
    {
        DataModel.Patient patient = new DataModel.Patient { Name = row["patient"] };
        DataModel.Dentist dentist = _model.Dentists.Find( d => d.Name == row["dentist"] );
        // checks to make sure there are no errors in the feature file supplied data.
        Assert.IsNotNull( dentist );
        _model.Patients.Add( patient );
        _model.Registrations.Add( new DataModel.Registration() { Patient = patient, Dentist = dentist } );
    }
}

[Given( @"The Current Appointments" )]
public void GivenTheCurrentAppointments( Table table )
{
    foreach( var row in table.Rows )
    {
        DataModel.Patient patient = _model.Patients.Find( p => p.Name == row["patient"] );
        DataModel.Dentist dentist = _model.Dentists.Find( d => d.Name == row["dentist"] );
        // checks to make sure there are no errors in the feature file supplied data.
        Assert.IsNotNull( patient );
        Assert.IsNotNull( dentist );

        DateTime dt = DateTime.ParseExact( row["appointment"], _dateTimeFormat, null );
        _model.Appointments.Add( new DataModel.Appointment() { Patient = patient, Dentist = dentist, At = dt } );
    }
}

[Given( @"Appointment slots are (.*) minutes" )]
public void GivenAppointmentSlotsAreMinutes( int minutes )
{
    _model.AppointmentSlotTime = minutes;
}

These Givens are setting up the data model for our scenarios, creating the dentist, patients, registrations and appointments that are provided via the parameters to the calls.. I've added some additional checks (Asserts) to ensure that errors in the feature supplied data are also captured by the given steps.

Implement the Whens


The Whens introduce an unknown functionality that we will use these steps to define, they are the Domain model. Specifically we introduced an AppointmentBooker class that allows us to manage bookings on the model, for now this will be a stub and we'll implement the details later.

[When( @"An appointment request '(.*)' is made for patient '(.*)'" )]
public void WhenAnAppointmentRequestIsMadeForPatient( string appointmentString, string patientName )
{
    DateTime appointmentTime = DateTime.ParseExact( appointmentString, _dateTimeFormat, null );
    DataModel.Patient patient = _model.Patients.Find( p => p.Name == patientName );

    DomainModel.AppointmentBooker booker = new DomainModel.AppointmentBooker( _model );

    try
    {
        ScenarioContext.Current.Add( "appointment", booker.MakeAnAppointment( patient, appointmentTime ) );
    }
    catch( DomainModel.AppointmentBookedException ex )
    {
        ScenarioContext.Current.Add( "appointment_exception", ex );
    }
}

Here we identify that the AppointmentBooker class needs a new method called MakeAnAppointment, taking as parameters a patient and a time to make an appointment. Both of these parameters have been derived from the parameters passed into the step from the feature file. At this point we decide we need the Appointment from the data model to be returned when an appointment is made, we also decide that failure to book an appointment will throw an exception to represent that the appointment could not be made. We could have chosen to return a null appointment at this point or any other multitude of design choices. This route was chosen as the scenario for the failure case required a set of alternate times, as I have chosen to embed within the exception.

The resultant objects are stored in the current ScenarioContexts dictionary (ScenarioContext.Current), we name these objects for retrieval in the Then steps for verification.

A more complete domain model, such as a controller on ASP.NET MVC would give us a different views for the success and failure cases allowing for a simpler step above.

Stub for the AppointmentBooker and the AppointmentBookedException, added to a new project called DomainModel, with the references added to the other projects:

public class AppointmentBooker
{
    public AppointmentBooker( DentistSurgeryModel model )
    {
    }


    public Appointment MakeAnAppointment( Patient patient, DateTime at )
    {
        throw new NotImplementedException();
    }
}

public class AppointmentBookedException : System.Exception
{
    public List<AppointmentSlot> Alternatives { get; }
}

The AppointmentSlot is defined as

public class AppointmentSlot
{
    public Dentist Dentist { get; }
    public DateTime SlotTime { get; }
}

Implement the Thens


The Then steps are usually verification steps to check the When steps give the correct expected results as defined from the scenario.

This first few Thens are easy to test, the appointment or appointment_exception are retrieved from the scenario context and asserted to be the expected results.

[Then( @"There is a confirmation that the appointment '(.*)' is booked for the patient '(.*)' with the dentist '(.*)'" )]
public void ThenThereIsAConfirmationThatTheAppointmentIsBookedForThePatientWithTheDentist( string appointmentString, string patientName, string dentistName )
{
    DateTime appointmentTime = DateTime.ParseExact( appointmentString, _dateTimeFormat, null );

    DataModel.Appointment appointment = ScenarioContext.Current["appointment"] as DataModel.Appointment;

    Assert.IsNotNull( appointment );
    Assert.AreEqual( patientName, appointment.Patient.Name );
    Assert.AreEqual( dentistName, appointment.Dentist.Name );
    Assert.AreEqual( appointmentTime, appointment.At );
}

[Then( @"The appointment '(.*)' is not available for dentist '(.*)'" )]
public void ThenTheAppointmentIsNotAvailableForDentist( string appointmentString, string dentistName )
{
    DateTime appointmentTime = DateTime.ParseExact( appointmentString, _dateTimeFormat, null );

    var appointments = from a in _model.Appointments
                       where a.Dentist.Name.Equals( dentistName ) && a.At.Equals( appointmentTime )
                       select a;

    Assert.AreEqual( 1, appointments.Count() );
}

[Then( @"The appointment request fails" )]
public void ThenTheAppointmentRequestFails()
{
    DomainModel.AppointmentBookedException appointmentBookedException = ScenarioContext.Current["appointment_exception"] as DomainModel.AppointmentBookedException;

    Assert.IsNotNull( appointmentBookedException );
}

The final Then is a more complicated test, as we have to create a range of appointment slots based on the information supplied from the feature file (there is an assumption on the working day, from 9am - 5pm, with lunch from 1pm to 2pm).

[Then( @"Alternative appointments '(.*)' for dentist '(.*)' for that day are given" )]
public void ThenAlternativeAppointmentsForDentistForThatDayAreGiven( string freeSlots, string dentistName )
{
    DomainModel.AppointmentBookedException appointmentBookedException = ScenarioContext.Current["appointment_exception"] as DomainModel.AppointmentBookedException;

    List<DateTime> freeSlotTimes = new List<DateTime>();

    // the feature offered an interesting notation on the available slots:
    // e.g. 13/9/13 9:00 - 13/9/13 10:45, 13/9/13 11:15 - 13/9/13 16:45
    // the code below creates all the slots within those time windows.
    var sections = freeSlots.Split( ',' ).Select( t => t.Trim().Trim( '\'' ) );
    foreach( var section in sections )
    {
        var bits = section.Split( '-' ).Select( t => t.Trim().Trim( '\'' ) ).ToArray();
        if( bits.Length == 2 )
        {
            var start = DateTime.ParseExact( bits[0], _dateTimeFormat, null );
            var end = DateTime.ParseExact( bits[1], _dateTimeFormat, null );
            for( DateTime time = start; time <= end; time += TimeSpan.FromMinutes( _model.AppointmentSlotTime ) )
            {
                freeSlotTimes.Add( time );
            }
        }
        else if( bits.Length == 1 )
        {
            freeSlotTimes.Add( DateTime.ParseExact( bits[0], _dateTimeFormat, null ) );
        }
    }
    // ensure distinct
    freeSlotTimes = freeSlotTimes.Distinct().ToList();
    // have to exclude lunch hour.
    freeSlotTimes.RemoveAll( time => (time >= (time.Date + TimeSpan.FromHours( 13 ))) && (time < (time.Date + TimeSpan.FromHours( 14 ))) );

    Assert.IsTrue( appointmentBookedException.Alternatives.TrueForAll( a => a.Dentist.Name.Equals( dentistName ) ) );

    // check that all the times in the inputTimes are also in the Alternatives.
    Assert.AreEqual( freeSlotTimes.Count(), appointmentBookedException.Alternatives.Count() );

    // simplest way to ensure that only the times we expect are in the alternatives is to do a join
    var joinedResults = from input in freeSlotTimes
                        join alt in appointmentBookedException.Alternatives on input equals alt.SlotTime
                        select input;

    Assert.AreEqual( freeSlotTimes.Count(), joinedResults.Count() );
}

We should now be ready to test.

Ready to test (fail)


Build the solution, resolve any missing project references. Open the Test Explorer Windows in Visual studio and run all the tests.



Notice that the names of the test, specifically the different variants of the SuccessfullyBooked scenarios, each one represents one of the example rows in the scenario. By adding more lines to the Examples in the feature file and recompiling more tests will appear in the Test Explorer.

Selecting a failed test will show you some more information about the test as usual for MsTest, however if you then Select the output, as shown below:


You then get the scenario as run displayed:


This shows the steps invoked and the parameters used, this is very useful in understanding why the test failed.

With failing tests, we can now implement the domain model.

Implement the domain model


The domain model is defined below:

public class AppointmentBooker
{
    DentistSurgeryModel _model;

    List<TimeSpan> _slots = new List<TimeSpan>();
        
    public AppointmentBooker( DentistSurgeryModel model )
    {
        _model = model;
            
        // populate the days slots - this is hard coded in this example.
        // working day is fixed from 9am to 5pm, with 1-2pm for lunch.
        // thats a 4 hour and 3 hour window.
        for( int i = 0; i < (4 * 60); i += _model.AppointmentSlotTime )
        {
            _slots.Add( TimeSpan.FromHours( 9 ) + TimeSpan.FromMinutes( i ) );
        }
        for( int i = 0; i < (3 * 60); i += _model.AppointmentSlotTime )
        {
            _slots.Add( TimeSpan.FromHours( 14 ) + TimeSpan.FromMinutes( i ) );
        }
    }

    public List<AppointmentSlot> FreeSlotsFor( Dentist dentist, DateTime date )
    {
        var q = from a in _model.Appointments
                where a.Dentist == dentist
                select a.At;

        var freeSlots = from s in _slots
                        where !q.Contains( date.Date + s )
                        select new AppointmentSlot( dentist, date.Date + s );
        return freeSlots.ToList();
    }

    public List<AppointmentSlot> FreeSlotsFor( Patient patient, DateTime date )
    {
        var myDentist = from reg in _model.Registrations
                    where reg.Patient == patient
                    select reg.Dentist;
        return FreeSlotsFor( myDentist.Take( 1 ).FirstOrDefault(), date ); 
    }

    /// <summary>
    /// The slot is not an appointment until it is made into one.
    /// </summary>
    /// <param name="patient"></param>
    /// <param name="at"></param>
    /// <returns></returns>
    public AppointmentSlot RequestSlot( Patient patient, DateTime at )
    {
        var slots = FreeSlotsFor( patient, at );
        var mySlot = (from s in slots
                        where s.SlotTime == at
                        select s).Take( 1 ).FirstOrDefault();
        return mySlot;
    }


    public Appointment MakeAnAppointment( Patient patient, AppointmentSlot slot )
    {
        var appointment = new Appointment() {Dentist=slot.Dentist, Patient=patient, At=slot.SlotTime};
        _model.Appointments.Add( appointment );
        return appointment;
    }

    public Appointment MakeAnAppointment( Patient patient, DateTime at )
    {
        AppointmentSlot slot = RequestSlot( patient, at );
        if( slot == null )
        {
            // use a better exception.
            throw new AppointmentBookedException( FreeSlotsFor( patient, at ), "Appointment not available" );
        }

        return MakeAnAppointment( patient, slot );
    }
}

public class AppointmentBookedException : System.Exception
{

    public AppointmentBookedException( List<AppointmentSlot> alternatives, string message = "" )
        :base( message )
    {
        if( alternatives == null )
        {
            alternatives = new List<AppointmentSlot>();
        }
        Alternatives = alternatives;
    }

    public List<AppointmentSlot> Alternatives { get; set; }
}

public class AppointmentSlot
{
    internal AppointmentSlot( Dentist dentist, DateTime slot )
    {
        Dentist = dentist;
        SlotTime = slot;
    }

    public Dentist Dentist { get; private set; }
    public DateTime SlotTime { get; private set; }
}

Ready to test (pass)


Build and run all the tests again. And now they all pass.


That's all there is to it. Its a journey but one well worth taking.

Download the full source code here.

No comments:

Post a Comment