¡Hola!
El otro día estuvimos viendo cómo podemos utilizar Autofixture para intentar mejorar la etapa de Arrange de nuestros tests y hacerlos más mantenibles; además, por si no fuese poco, también vimos cómo aumenta nuestra productividad ya que tenemos que escribir menos código, o más bien, menos código que no está directamente relacionado con el objetivo del test, por ejemplo, para probar x funcionalidad necesitamos una instancia de y, nos da igual su estado, simplemente la necesitamos, y ahí es donde Autofixture nos puede ayudar. Esto último no siempre es así, es muy posible que en ciertos test queramos crear instancias en un estado concreto ya sea porque es lo que estamos probando o porque está directamente relacionado, y para ello existen técnicas como factorías o builders (en el anterior post vimos algunas técnicas), pero Autofixture también nos permite personalizar cómo creamos los objetos.
Vamos a imaginar que tenemos la siguiente clase que representa un producto:
1 2 3 4 5 6 7 8 9 10 | public class Product { public string Name { get; set; } public double Cost { get; set; } public bool IsExpensive() { return this.Cost > 999; } } |
Podríamos hacer lo siguiente con Autofixture para generar un producto aleatorio:
1 2 3 4 5 6 7 8 9 | [Fact] public void CreateProduct_Test() { IFixture fixture = new Fixture(); var product = fixture.Create<Product>(); Assert.NotNull(product); } |
Si ejecutamos el test se pondrá en verde, es decir, ha creado un producto aleatorio. El problema es que muchas veces necesitamos, por ejemplo, un producto que contenga cierto nombre. Además, podemos querer reutilizar la forma en la que configuramos ese producto y no copiar y pegar código por todos los tests. Para solucionar esto, una de las herramientas que tenemos es la interfaz ICustomization.
1 2 3 4 5 6 7 | public class DummyProductCustomization: ICustomization { public void Customize(IFixture fixture) { fixture.Customize<Product>(comp => comp.With(p => p.Name, "Dummy Product")); } } |
Simplemente utilizamos el método Customize para personalizar la creación de nuestro producto. Lo utilizaríamos así:
1 2 3 4 5 6 7 8 9 | [Fact] public void AllProducts_Are_DummyProduct_Test() { IFixture fixture = new Fixture().Customize(new DummyProductCustomization()); var product = fixture.Create<Product>(); Assert.Equal("Dummy Product", product.Name); } |
Autofixture nos proporciona una implementación de ICustomization, llamada CompositeCustomization, que nos va a permitir encadenar varias customizaciones. Además de la customización que vimos antes (DummyProductCustomization), vamos a crear otra para crear productos caros:
1 2 3 4 5 6 7 | public class ExpensiveProductCustomization: ICustomization { public void Customize(IFixture fixture) { fixture.Customize<Product>(comp => comp.With(p => p.Cost, double.MaxValue)); } } |
Ahora vamos a crear con CompositeCustomization una nueva customización que nos proporcionará productos con nombre Dummy Product y que además sean caros:
1 2 3 4 5 6 7 | public class ExpensiveDummyProductCompositeCustomization : CompositeCustomization { public ExpensiveDummyProductCompositeCustomization() : base(new DummyProductCustomization(), new ExpensiveProductCustomization()) { } } |
Simplemente añadimos al constructor de CompositeCustomization todas las customizaciones que queremos añadir. Vamos a crear un test que verifique que funciona bien:
1 2 3 4 5 6 7 8 9 10 | [Fact] public void AllProducts_Are_DummyProductAndExpensive_Test() { IFixture fixture = new Fixture().Customize(new ExpensiveDummyProductCompositeCustomization()); var product = fixture.Create<Product>(); Assert.True(product.IsExpensive()); Assert.Equal("Dummy Product", product.Name); } |
Ups, test en rojo 🙁 ¿Qué es lo que está ocurriendo? Realmente estamos machacando una customización con la otra, es decir, por la forma en que está desarrollado Autofixture importa y mucho el orden en que las customizaciones son añadidas. En el anterior caso, primero se añade DummyProductCustomization, y posteriormente ExpensiveProductCustomization, por lo que esta última prevalece y el producto que se creará sí que será caro, pero su nombre no será Dummy Product. Con esto vemos que CompositeCustomization es útil cuando lo utilizamos sobre tipos diferentes, por ejemplo, si queremos crear un usuario llamado Pepe (clase User) con un producto (clase Product) caro (veremos un ejemplo más adelante). Para resolver este caso que vemos, la opción sería crear una clase que implemente ICustomization y establecer toda la customización (nombre Dummy Product y caro):
1 2 3 4 5 6 7 8 9 | public class ExpensiveDummyProductCustomization : ICustomization { public void Customize(IFixture fixture) { fixture.Customize<Product>(comp => comp.With(p => p.Name, "Dummy Product"). With(p=>p.Cost, double.MaxValue)); } } |
Si creamos un test añadiendo ExpensiveDummyProductCustomization, vemos como se pone en verde:
1 2 3 4 5 6 7 8 9 10 | [Fact] public void AllProducts_Are_DummyProductAndExpensive_Test() { IFixture fixture = new Fixture().Customize(new ExpensiveDummyProductCustomization()); var product = fixture.Create<Product>(); Assert.True(product.IsExpensive()); Assert.Equal("Dummy Product", product.Name); } |
La clase Product que hemos estado utilizando en los ejemplos es muy sencilla; vamos a complicarlo un poco.
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 | public class User { public string Name { get; } public string Password { get;} public string Email { get; } public Country Country { get; set; } public DateTime BornDate { get; set; } public Product Product { get; set; } public User(string name, string password, string email) { if (string.IsNullOrEmpty(name)) throw new ArgumentException("must be at least one character", nameof(name)); if (string.IsNullOrEmpty(password) || password.Length < 4 || password.Length > 12) throw new ArgumentException("must be between 4 and 12 characters", nameof(password)); if(!Validator.EmailIsValid(email)) throw new ArgumentException("wrong email address formats", nameof(email)); this.Name = name; this.Password = password; this.Email = email; } public bool IsAdult() { return this.Country?.Name == "ES" && DateTime.Today.Year - this.BornDate.Year >= 18; } } |
La clase User tiene un constructor con 3 parámetros con ciertas restricciones; name debe contener al menos un caracter, password debe contener entre 4 y 12 caracteres y email debe tener un formato de email válido. Además, tiene como propiedades la fecha de nacimiento del usuario BornDate, país Country y un producto asociado Product. Por último, dispone de un método que comprueba si un usuario es adulto, para ello verifica si el nombre del país es «ES» y si han pasado 18 años desde su nacimiento. Es un ejemplo, vamos a obviar los problemas que tiene esta clase… La clase Product es la que hemos estado utilizando en los anteriores ejemplos, y la clase Country es la siguiente:
1 2 3 4 5 6 7 8 9 | public class Country { public string Name { get; } public Country(string name) { this.Name = name; } } |
¿Qué pasa si creamos intentamos crear con Autofixture un nuevo usuario?
1 2 3 4 5 6 7 8 9 | [Fact] public void CreateUser_Test() { Fixture fixture = new Fixture(); User user = fixture.Create<User>(); Assert.NotNull(user); } |
Si ejecutamos el test, falla. La razón es muy sencilla, y es que los parámetros del constructor de la clase User tienen restricciones. En concreto va a fallar al comprobar la contraseña ya que los valores que va a crear como argumentos del constructor User tienen un aspecto parecido a esto «nombreDelParámetrob010ea34-3b4c-45d6-af0f-548a37def82f». En cualquier caso, si por ejemplo un parámetro del constructor fuese un entero y se comprobase que estuviese en cierto rango, ocasionalmente no lanzaría excepción… en cualquier caso, nuestro test no sería determinista. La solución que vimos antes añadiendo ICustomization no nos vale, pues las customizaciones se aplican a posteriori, es decir, se crearía el usuario (que lanzaría excepción) y luego se modificarían sus propiedades. Para poder crear usuarios con Autofixture vamos a tener que hacer uso de ISpecimenBuilder. ISpecimenBuilder es una interfaz que únicamente tiene un método Create con dos parámetros. Tiene el siguiente aspecto:
1 2 3 4 | public interface ISpecimenBuilder { object Create(object request, ISpecimenContext context); } |
Si añadimos nuestro propia implementación de ISpecimenBuilder, cuando solicitemos la creación de una variable, la petición va a pasar varias veces por nuestro SpecimenBuilder y tendremos que comprobar si el objeto request cumple con las características que queremos y, en tal caso, la capturamos y devolvemos el valor que nos interese. Vamos a ver un ejemplo de cómo capturaríamos los parámetros que se van a pasar al constructor de nuestro usuario para que cumplan los requisitos que necesitamos:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class UserSpecimenBuilder : ISpecimenBuilder { public object Create(object request, ISpecimenContext context) { if(request is ParameterInfo parameterInfo && parameterInfo.ParameterType == typeof(string)) { if(parameterInfo.Name == "password") return "leT455"; if (parameterInfo.Name == "email") return "emailAddress@email.com"; } return new NoSpecimen(); } } |
Vamos por partes. Lo primero que hacemos es comprobar si request es un parámetro y su tipo es string. En caso afirmativo, si el nombre del parámetro es «password» o «email», devolvemos los valores que nos interesan en cada caso. Cuando no se cumple lo anterior devolvemos NoSpecimen, que quiere decir que omita el ISpecimenBuilder actual y compruebe los siguientes SpecimenBuilders (si los hay). Ojo, devolver null contaría como si se devolviese un valor para la request actual, para omitir el ISpecimenBuilder hay que utilizar NoSpecimen. Hay que tener en cuenta que, en cuanto se devuelve un valor válido (que no sea NoSpecimen) no se comprobarán los siguientes ISpecimenBuilder disponibles por lo que, si añadimos varios ISpecimenBuilder, en cuanto una request sea capturada por uno de ellos, no pasará al siguiente. Comprobamos que el ISpecimenBuilder anterior funciona correctamente:
1 2 3 4 5 6 7 8 9 10 | [Fact] public void UserIsCreated_With_UserSpecimenBuilder_Test() { Fixture fixture = new Fixture(); fixture.Customizations.Add(new UserSpecimenBuilder()); User user = fixture.Create<User>(); Assert.NotNull(user); } |
Como decía antes, se puede añadir varios ISpecimenBuilder. Vamos a separar UserSpecimenBuilder en dos, uno para la contraseña y otro para el email:
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 | public class PasswordSpecimenBuilder: ISpecimenBuilder { public object Create(object request, ISpecimenContext context) { if (request is ParameterInfo parameterInfo && parameterInfo.ParameterType == typeof(string) && parameterInfo.Name == "password") { return "leT455"; } return new NoSpecimen(); } } public class EmailSpecimenBuilder: ISpecimenBuilder { public object Create(object request, ISpecimenContext context) { if (request is ParameterInfo parameterInfo && parameterInfo.ParameterType == typeof(string) && parameterInfo.Name == "email") { return "emailAddress@email.com"; } return new NoSpecimen(); } } } |
Ahora comprobamos con un test que si añadimos ambos funciona correctamente:
1 2 3 4 5 6 7 8 9 10 11 | [Fact] public void UserIsCreated_With_MultipleSpecimenBuilder_Test() { Fixture fixture = new Fixture(); fixture.Customizations.Add(new PasswordSpecimenBuilder()); fixture.Customizations.Add(new EmailSpecimenBuilder()); User user = fixture.Create<User>(); Assert.NotNull(user); } |
Lo anterior lo podemos hacer creando un ICustomization que añada nuestros dos ISpecimenBuilder:
1 2 3 4 5 6 7 8 | public class UserCustomization : ICustomization { public void Customize(IFixture fixture) { fixture.Customizations.Add(new PasswordSpecimenBuilder()); fixture.Customizations.Add(new EmailSpecimenBuilder()); } } |
Igual que antes, creamos un test para comprobar que funciona correctamente:
1 2 3 4 5 6 7 8 9 | [Fact] public void UserIsCreated_With_UserCustomization_Test() { IFixture fixture = new Fixture().Customize(new UserCustomization()); User user = fixture.Create<User>(); Assert.NotNull(user); } |
Test en verde.
Otra opción más, si utilizamos XUnit, es con el atributo AutoDataAttribute (dentro del namespace AutoFixture.Xunit2). Este atributo hereda de DataAttribute (aquí hablé de él), y permite añadir por constructor una función que creará nuestro Fixture. Por ejemplo, vamos a crear uno que utilice el UserCustomization que acabamos de ver:
1 2 3 4 5 6 7 | public class UserDataAttribute: AutoDataAttribute { public UserDataAttribute(): base(()=>new Fixture().Customize(new UserCustomization())) { } } |
Ahora podemos directamente añadir como parámetro a nuestro test directamente un User:
1 2 3 4 5 | [Theory,UserDataAttribute] public void UserIsCreated_With_UserDataAttribute_Test(User user) { Assert.NotNull(user); } |
La opción de crear un atributo es bastante interesante si lo utilizamos junto con AutoMoqCustomization (en el anterior post hablamos de él) y pasamos las dependencias moqueadas como parámetro decorándolas con el atributo [Frozen] (puedes ver un ejemplo aquí).
Vamos a empezar a mezclar un poco las customizaciones. Ahora nos imaginamos que queremos un usuario que sea español, es decir, que su propiedad Country.Name sea «ES». Para ello necesitamos crear un usuario válido (utilizaremos UserCustomization que vimos antes) y que tenga un Country con las características apropiadas. Creamos un ISpecimenBuilder para el constructor de Country:
1 2 3 4 5 6 7 8 9 10 11 12 13 | public class SpanishCountrySpecimenBuilder : ISpecimenBuilder { public object Create(object request, ISpecimenContext context) { if (request is ParameterInfo parameterInfo && parameterInfo.ParameterType == typeof(string) && parameterInfo.Name == "name") { return "ES"; } return new NoSpecimen(); } } |
Ahora creamos un ICustomization que utilice UserCustomization y SpanishCountrySpecimenBuilder:
1 2 3 4 5 6 7 8 | public class SpanishUserCustomization : ICustomization { public void Customize(IFixture fixture) { fixture.Customizations.Add(new SpanishCountrySpecimenBuilder()); fixture.Customize(new UserCustomization()); } } |
Y por último el test que comprueba que creamos un User español:
1 2 3 4 5 6 7 8 9 | [Fact] public void CreateSpanishUser_Test() { IFixture fixture = new Fixture().Customize(new SpanishUserCustomization()); User user = fixture.Create<User>(); Assert.Equal("ES", user.Country.Name); } |
Ejecutamos el test y pasa. Aquí hay un problema, y es que en SpanishCountrySpecimenBuilder estamos buscando un parámetro que se llame «name» y sea string y, en ese caso, devolvemos «ES». Justamente User en su constructor tiene un parámetro «name» que es string, es más, si comprobamos la propiedad Name de User (user.Name) es «ES». En nuestro caso nos da igual, pero si la comprobación que se hace del nombre de usuario fuese que debe de contener al menos 3 caracteres, tenemos un problema. La verdad que, tal y como está planteado SpanishUserCustomization, no he encontrado una solución directa, aunque es posible que la haya (tampoco la he buscado mucho…).
¿Y si queremos crear un usuario que, además de español, sea mayor de edad? Si recordamos, User tiene una chapuza un método para comprobar si un usuario es adulto; pues vamos a crear un nuevo ICustomization que añada nuestro SpanishUserCustomization y establezca el año de nacimiento del usuario (BornDate):
1 2 3 4 5 6 7 8 9 | public class AdultUserCustomization : ICustomization { public void Customize(IFixture fixture) { fixture.Customize(new SpanishUserCustomization()); fixture.Customize<User>(comp => comp.With(p => p.BornDate, DateTime.Now.AddYears(-25))); } } |
Comprobamos que se crea un usuario español mayor de edad:
1 2 3 4 5 6 7 8 9 10 | [Fact] public void CreateSpanishAdultUser_Test() { IFixture fixture = new Fixture().Customize(new AdultUserCustomization()); User user = fixture.Create<User>(); Assert.True(user.IsAdult()); Assert.Equal("ES", user.Country.Name); } |
Si recordamos, cuando vimos los ejemplos con Product vimos que había una funcionalidad para añadir varios ICustomization llamada CompositeCustomization. También dijimos que no es muy recomendable cuando se aplican sobre el mismo tipo, pero vamos a ver que aplicándola sobre diferentes tipos sí que es muy útil. Ahora queremos crear un usuario que tenga un producto llamado DummyProduct, para ello juntaremos UserCustomization y DummyProductCustomization (lo vimos al principio del post):
1 2 3 4 5 6 7 8 | public class UserWithDummyProductCustomization : CompositeCustomization { public UserWithDummyProductCustomization() : base(new UserCustomization(), new DummyProductCustomization()) { } } |
Comprobamos en un test que todo es correcto:
1 2 3 4 5 6 7 8 9 | [Fact] public void CreateUser_With_DummyProduct_Test() { IFixture fixture = new Fixture().Customize(new UserWithDummyProductCustomization()); User user = fixture.Create<User>(); Assert.Equal("Dummy Product", user.Product.Name); } |
Conclusiones
Después de dar un repaso a algunas de las formas para customizar la creación con Autofixture, no tengo muy claro que me convenza su uso cuando la creación de objetos es compleja. Por un lado parece que podemos encapsular y reutilizar los diferentes ISpecimenBuilder y ICustomization, pero también hay que tener mucho cuidado de utilizarlos correctamente. Además, en concreto cuando utilizamos ISpecimenBuilder, hay comprobaciones de tipos, de nombres de parámetros… que puede que nos complique las refactorizaciones de código de los test, o por lo menos, nos las haga un poco más pesadas. En resumen, muy posiblemente sea porque no he profundizado demasiado en Autofixture, pero cuando la creación de objetos es compleja y hay comprobaciones en los constructores, creo que preferiría utilizar una factoría, un Data Builder u otra técnica y prescindir de Autofixture, porque una de las cosas buenas de Autofixture es que nos permite desacoplarnos de los constructores, pero si hay ciertas reglas que deben seguir los parámetros de los constructores, directa o indirectamente estamos acoplados a ellos.
Puedes ver todo el código de los ejemplos en Github.
¡Un saludo!