BDD en .NET con SpecFlow

¡Hola!
SpecFlow es un framework que nos va a permitir aplicar BDD (Behavior-Driven Development o Desarrollo guiado por comportamiento) a nuestros proyectos de una forma bastante sencilla utilizando Gherkin como lenguaje para definir nuestros escenarios. En otras palabras, SpecFlow nos va a permitir construir pruebas entendibles desde negocio y reflejarlas en nuestros test, por ejemplo, reflejar una historia de usuario que un humano entienda, en un test en nuestro código. Antes que nada quiero advertir que no tengo mucha ni idea de BDD, así que es posible que no sea muy riguroso el post (¿¿alguno lo es??), por lo que me disculpo por adelantado 🙂 En cualquier caso el objetivo es explorar un poco la herramienta SpecFlow que me parece muy interesante, y dicho esto, vamos a ello.

El primer paso va a ser instalar la extensión SpecFlow para Visual Studio, en mi caso he instalado SpecFlow for Visual Studio 2017. Hecho esto ya tenemos las plantillas y demás cosas necesarias para poder utilizar la herramienta. Para el ejemplo que vamos a ver, vamos a seguir la kata RPG Combat Kata, que se basa en ir implementando las diferentes iteraciones progresivamente y sin ver las siguientes, es decir, implementamos la iteración 1 sin tener en cuenta la 2 y así sucesivamente. En nuestro caso sólo vamos a implementar la iteración 1, cuyas instrucciones son las siguientes:

1. All Characters, when created, have:
   Health, starting at 1000
   Level, starting at 1
   May be Alive or Dead, starting Alive (Alive may be a true/false)
2. Characters can Deal Damage to Characters.
   Damage is subtracted from Health
   When damage received exceeds current Health, Health becomes 0 and the character dies
3. A Character can Heal a Character.
   Dead characters cannot be healed
   Healing cannot raise health above 1000 

Creamos una nueva solución y añadimos dos proyectos (uno de ellos de test). Dentro del proyecto de test incluimos una carpeta Features y otra StepDefinitions (por ejemplo). En la carpeta Features deberíamos ir añadiendo los archivos que vayamos generando con SpecFlow y en StepDefinitions los test que vamos a generar. Al final de lo que se va a tratar es de generar un archivo *.feature con el asistente de SpecFlow (que añadiremos a la carpeta Features), que contendrá las definición de nuestros test con lenguaje Gherkin (entendible por humanos) y, a partir de estas reglas, se van a crear automáticamente nuestros test (con NUnit) que situaremos en la carpeta StepDefinitions. La cosa quedaría más o menos así:
Empezamos añadiendo nuestro archivo que contendrá la Feature de la iteración 1 de la kata, es decir, la que hemos visto un poco más arriba:
En este caso a nuestra Feature le vamos a llamar IterationOne.feature ya que en el contexto de la kata representa una Feature a implementar. Vamos a abrir el archivo que acabamos de generar (IterationOne.feature) y vamos a generar (con Gherkin) nuestro primer escenario:

Scenario: Creation of a character
    Given A new character
    Then the health starting at 1000
    And the level starting at 1
    And starting alive

Dejando de lado mi inglés, vemos que es muy sencillo, nuestro escenario representa la creación de un personaje y las características que se deben de cumplir cuando es creado. Ahora que ya tenemos definido qué es lo que tenemos que hacer, vamos a generar el test asociado. Para ello hacemos clic con el botón derecho encima del archivo y pulsamos en la opción Generate Step Definitions:
Se nos abrirá un asistente para seleccionar los pasos que queremos generar (en nuestro caso todos) y el nombre que queremos darle a la clase de test que se va a crear a partir de nuestra feature (IterationOneSteps):
Y voilá, se ha creado nuestro archivo de test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    [Binding]
    public class IterationOneSteps
    {
        [Given(@"A new character")]
        public void GivenANewCharacter()
        {
            ScenarioContext.Current.Pending();
        }
 
        [Then(@"the health starting at (.*)")]
        public void ThenTheHealthStartingAt(int p0)
        {
            ScenarioContext.Current.Pending();
        }
 
        [Then(@"the level starting at (.*)")]
        public void ThenTheLevelStartingAt(int p0)
        {
            ScenarioContext.Current.Pending();
        }
 
        [Then(@"starting alive")]
        public void ThenStartingAlive()
        {
            ScenarioContext.Current.Pending();
        }
    }

Es bastante sencillo de ver lo que ha ocurrido, y es que cada uno de los pasos que hemos definido en nuestro archivo IterationOne.feature, se representa como un paso (step) en nuestro test. Además de los atributos que decoran los diferentes métodos y la clase, hay algo bastante interesante y es que, por ejemplo en el método ThenTheHealthStartingAt, recibe un parámetro p0 de tipo integer. Si revisamos de nuevo nuestro archivo IterationOne.feature en la línea Then the health starting at 1000, nos podemos dar cuenta que ha sustituido el valor 1000 por un parámetro, es decir, que cuando se ejecute el paso ThenTheHealthStartingAt vamos a recibir en este caso el valor 1000 como parámetro p0; esto nos hace intuir que vamos a poder reutilizar y parametrizar los diferentes pasos de nuestros test.. Si ejecutásemos el test ahora mismo nos diría lo siguiente:

    One or more step definitions are not implemented yet.
    IterationOneSteps.GivenANewCharacter() Given A new character -> pending: IterationOneSteps.GivenANewCharacter()
    Then the health starting at 1000 -> skipped because of previous errors
    And the level starting at 1 -> skipped because of previous errors
    And starting alive -> skipped because of previous errors

En esencia nos está diciendo que no hemos implementado ninguno de los diferentes pasos que tiene nuestro Escenario. Esto es porque todavía no hemos creado la funcionalidad que cumpla el test y en cada uno de los pasos hay añadido ScenarioContext.Current.Pending(). Antes de crear la funcionalidad, como detalle, si nos fijamos el archivo IterationOne.feature tiene asociado un archivo IterationOne.feature.cs, que es el código que se genera automáticamente a partir de nuestra feature y es el que realmente contiene el test y el que se va a encargar de crear y gestionar nuestro Escenario (IterationOneSteps):
Ahora que ya tenemos todo preparado, vamos a añadir la funcionalidad que hemos definido en el test y de paso refactorizar los valores de los parámetros que recibimos en los diferentes métodos (p0):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    [Binding]
    public class IterationOneSteps
    {
        private Character character;
 
        [Given(@"A new character")]
        public void GivenANewCharacter()
        {
            character = new Character();
        }
 
        [Then(@"the health starting at (.*)")]
        public void ThenTheHealthStartingAt(int health)
        {
            Assert.AreEqual(health, character.Health);
        }
 
        [Then(@"the level starting at (.*)")]
        public void ThenTheLevelStartingAt(int level)
        {
            Assert.AreEqual(level, character.Level);
        }
 
        [Then(@"starting alive")]
        public void ThenStartingAlive()
        {
            Assert.IsTrue(character.IsAlive);
        }
    }

Ahora vamos a crear nuestra clase Character:

1
2
3
4
5
6
7
8
9
10
11
12
13
    public class Character
    {
        public int Health { get; set; }
        public int Level { get; set; }
 
        public bool IsAlive => Health > 0;
 
        public Character()
        {
            this.Health = 1000;
            this.Level = 1;
        }
    }

Si ejecutamos ahora el test ya se pone en verde 🙂
Vamos a seguir con el punto 2 de la iteración 1 que lo incluiré en el archivo feature que ya habíamos creado, aunque podríamos crear uno nuevo. Como el punto 2 nos da realmente dos casos (los personajes pueden recibir daño y qué ocurre cuando reciben un daño mayor que su vida actual), vamos a añadir dos escenarios distintos:

Scenario: Characters can Deal Damage to Characters
    Given A character with 800 of health
    When is attacked with 100 of damage
    Then the health is 700

Scenario: Health can not be less than 0
    Given A character with 800 of health
    When is attacked with 900 of damage
    Then the health is 0
    And is dead

Repetimos la operación que hicimos antes, botón derecho y Generate Step Definitions. Se nos va a volver a abrir la ventana Generate Step Definition Skeleton pero ahora, puesto que no quiero crear un archivo de Steps nuevo, pulsamos en Copy methods to clipboard y vamos a copiar directamente los nuevos métodos en nuestro archivo IterationOneSteps:
Podemos ver que nuestra clase IterationOneSteps ahora contiene los nuevos métodos junto con los anteriores:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
    [Binding]
    public class IterationOneSteps
    {
        private Character character;        
 
        [Given(@"A new character")]
        public void GivenANewCharacter()
        {
            character = new Character();
        }
 
        [Given(@"A character with (.*) of health")]
        public void GivenACharacterWithOfHealth(int p0)
        {
            ScenarioContext.Current.Pending();
        }
 
        [When(@"is attacked with (.*) of damage")]
        public void WhenIsAttackedWithOfDamage(int p0)
        {
            ScenarioContext.Current.Pending();
        }
 
        [Then(@"the health starting at (.*)")]
        public void ThenTheHealthStartingAt(int health)
        {
            Assert.AreEqual(health, character.Health);
        }
 
        [Then(@"the level starting at (.*)")]
        public void ThenTheLevelStartingAt(int level)
        {
            Assert.AreEqual(level, character.Level);
        }
 
        [Then(@"starting alive")]
        public void ThenStartingAlive()
        {
            Assert.IsTrue(character.IsAlive);
        }
 
        [Then(@"the health is (.*)")]
        public void ThenTheHealthIs(int p0)
        {
            ScenarioContext.Current.Pending();
        }
 
        [Then(@"is dead")]
        public void ThenIsDead()
        {
            ScenarioContext.Current.Pending();
        }
    }

Igual que antes, vamos a cambiar ScenarioContext.Current.Pending() por lo que debemos hacer en cada paso:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
    [Binding]
    public class IterationOneSteps
    {
        private Character character;
 
        [Given(@"A new character")]
        public void GivenANewCharacter()
        {
            character = new Character();
        }
 
        [Given(@"A character with (.*) of health")]
        public void GivenACharacterWithOfHealth(int health)
        {
            character = new Character { Health = health };
        }
 
        [When(@"is attacked with (.*) of damage")]
        public void WhenIsAttackedWithOfDamage(int attackDamage)
        {
            Character enemy = new Character();
            enemy.Attack(character, attackDamage);
        }
 
        [Then(@"the health starting at (.*)")]
        public void ThenTheHealthStartingAt(int health)
        {
            Assert.AreEqual(health, character.Health);
        }
 
        [Then(@"the level starting at (.*)")]
        public void ThenTheLevelStartingAt(int level)
        {
            Assert.AreEqual(level, character.Level);
        }
 
        [Then(@"starting alive")]
        public void ThenStartingAlive()
        {
            Assert.IsTrue(character.IsAlive);
        }
 
        [Then(@"the health is (.*)")]
        public void ThenTheHealthIs(int health)
        {
            Assert.AreEqual(health, character.Health);
        }
 
        [Then(@"is dead")]
        public void ThenIsDead()
        {
            Assert.IsFalse(character.IsAlive);
        }
    }

Si ejecutamos los test… no puede compilar… es momento de seguir añadiendo funcionalidad a la clase Character:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    public class Character
    {
        public int Health { get; set; }
        public int Level { get; set; }
 
        public bool IsAlive => Health > 0;
 
        public Character()
        {
            this.Health = 1000;
            this.Level = 1;
        }
 
        public void Attack(Character target, int attackDamage)
        {
            target.ReceiveAttack(attackDamage);
        }
 
        private void ReceiveAttack(int attackDamage)
        {
            this.Health -= attackDamage;
            if (this.Health < 0)
                this.Health = 0;
        }
    }

Si ahora ejecutamos los test (tenemos uno por cada Escenario, es decir, 3), se ponen en verde.
Ya sólo nos queda la última parte de la iteración 1, vamos a añadirla a nuestro archivo IterationOne.feature

Scenario: Dead characters cannot be healed
	Given A character with 0 of health
	When is healed with 100
	Then the health is 0

Scenario: Healing cannot raise health above 1000
	Given A character with 900 of health
	When is healed with 150
	Then the health is 1000

Igual que en el paso anterior, he decido dividirlo en dos escenarios diferentes. Si revisamos todos los escenarios que hemos creado, aquí estamos repitiendo muchos de los pasos que ya habíamos creado anteriormente. Por ejemplo, ahora mismo hemos añadido Given A character with 0 of health y en el anterior paso habíamos añadido Given A character with 800 of health, es decir, sólo se diferencian en el número de vida (0 vs 800). Si recordamos, éste valor se recibía automáticamente como parámetro en el método GivenACharacterWithOfHealth(int health) de la clase IterationOneSteps, ¿qué quiere decir esto?, pues que ese método se va a reutilizar. Si echamos un vistazo al archivo feature, vemos que sólo está resaltado en otro color dos líneas:
Por lo que cuando hagamos botón derecho y pulsemos la opción Generate Step Definitions sólo nos van a aparecer los dos nuevos métodos que son necesarios:
Realmente al copiar los métodos sólo nos va a copiar un método, puesto que si nos fijamos la sentencia es la misma y sólo se diferencia en el valor (When is healed with [valor]). Vamos a añadir entonces nuestro nuevo método a la clase IterationOneSteps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
    [Binding]
    public class IterationOneSteps
    {
        private Character character;
 
        [Given(@"A new character")]
        public void GivenANewCharacter()
        {
            character = new Character();
        }
 
        [Given(@"A character with (.*) of health")]
        public void GivenACharacterWithOfHealth(int health)
        {
            character = new Character { Health = health };
        }
 
        [When(@"is attacked with (.*) of damage")]
        public void WhenIsAttackedWithOfDamage(int attackDamage)
        {
            Character enemy = new Character();
            enemy.Attack(character, attackDamage);
        }
 
        [When(@"is healed with (.*)")]        public void WhenIsHealedWith(int healedValue)        {            Character otherCharacter = new Character();            otherCharacter.Heal(character, healedValue);        } 
 
        [Then(@"the health starting at (.*)")]
        public void ThenTheHealthStartingAt(int health)
        {
            Assert.AreEqual(health, character.Health);
        }
 
        [Then(@"the level starting at (.*)")]
        public void ThenTheLevelStartingAt(int level)
        {
            Assert.AreEqual(level, character.Level);
        }
 
        [Then(@"starting alive")]
        public void ThenStartingAlive()
        {
            Assert.IsTrue(character.IsAlive);
        }
 
        [Then(@"the health is (.*)")]
        public void ThenTheHealthIs(int health)
        {
            Assert.AreEqual(health, character.Health);
        }
 
        [Then(@"is dead")]
        public void ThenIsDead()
        {
            Assert.IsFalse(character.IsAlive);
        }
    }

Ya sólo nos queda acabar de completar la clase Character:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
    public class Character
    {
        public int Health { get; set; }
        public int Level { get; set; }
 
        public bool IsAlive => Health > 0;
 
        public Character()
        {
            this.Health = 1000;
            this.Level = 1;
        }
 
        public void Attack(Character target, int attackDamage)
        {
            target.ReceiveAttack(attackDamage);
        }
        public void Heal(Character target, int healedValue)
        {
            target.ReceiveHeal(healedValue);
        }
 
        private void ReceiveAttack(int attackDamage)
        {
            this.Health -= attackDamage;
            if (this.Health < 0)
                this.Health = 0;
        }
        private void ReceiveHeal(int healValue)
        {
            if (!this.IsAlive)
                return;
 
            this.Health += healValue;
            if (this.Health > 1000)
                this.Health = 1000;
        }
    }

Ejecutamos todos nuestros test y se ponen en verde, ya hemos completado la iteración 1 de la Kata RPG Combat Kata usando SpecFlow 🙂

Conclusiones

Como hemos visto es una herramienta que puede ser muy interesante siempre y cuando haya gente de negocio capaz de definir más o menos bien nuestros diferentes escenarios. En cualquier caso sólo hemos rascado la superficie tanto de Gherkin como de SpecFlow. Más adelante veremos (a lo mejor), cómo poder añadir variables a nuestro escenario y así no tener que definirlas como variables de instancia de nuestra clase de test y el ciclo de vida de los diferentes escenarios, cómo poder parametrizar nuestras features y pasar datos más complejos (no sólo integers), Hooks…

¡Un saludo!

Responder a Anónimo Cancelar respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *