Skip to content

Pruebas

Vanskarner edited this page Sep 7, 2023 · 7 revisions

La elaboración de esta sección se basa en el libro XUnit Test Patterns: Refactoring Test Code (2007) de Gerard Meszaros, presentando el contenido de manera simplificada y centrándose en los fragmentos esenciales para comprender lo importante en las pruebas. Para obtener información más detallada sobre los patrones relacionados con las pruebas y otros conceptos, se recomienda consultar el libro.

Testing

Toda buena arquitectura dispone de pruebas que ayuden a garantizar la calidad del software, validar los cambios, mantener la integridad de la arquitectura y facilitar la refactorización. En otras palabras, las pruebas, al igual que el código de producción, también se diseñan para generar estructuras de alta cohesión y bajo acoplamiento.

Aspectos

  • Las pruebas, al igual que el código de producción, utilizan los principios de SOLID y los principios de componentes.
  • Siguen la regla de dependencia de la Arquitectura Limpia.
  • Su función es apoyar el desarrollo y no la operación.
  • Una prueba que es difícil de probar generalmente está mal diseñada.
  • Todas las pruebas son iguales desde un punto de vista arquitectónico.

Convenciones de nomenclatura

Por lo general, las convenciones de nomenclatura dependerán de su equipo de desarrollo; sin embargo, en términos generales, se pueden resumir de la siguiente manera:

Cuando necesitas agregar contexto Cuando no se necesita del contexto
[Característica a probar] + [Escenario de prueba] + [Comportamiento esperado]

[Característica a probar] + [Comportamiento esperado]

Ejemplos para Use Cases:

execute_withValidID_itemExists
testExecuteWithValidIDShouldBeTheItem
Ejemplos para Use Cases:

execute_numberItemsDeleted
testExecuteShouldGetNumberItemsDeleted

Conceptos básicos

  1. Fixtures: También conocido como "Test Fixtures", se refiere a todo lo necesario para poder ejecutar el sistema bajo prueba (SUT). Podemos considerarlo como "accesorios de prueba".

  2. System Under Test (SUT): El sistema bajo prueba es todo aquello que se está probando. Esto puede incluir una clase, un método, un componente o incluso la aplicación completa, dependiendo del tipo de prueba que se esté llevando a cabo.

Ejemplo

public class CheckItemUseCaseTest {

    @Test
    public void execute_withValidID_itemExists() throws Exception {
        //Inicio de Fixtures
        Item item = createSampleItem();
        ExecutorService executorService = TestExecutorServiceFactory.create();
        Repository fakeRepository = FakeRepositoryFactory.createRepository();
        fakeRepository.saveItem(item).await();        
        CheckItemUseCase useCase = new CheckItemUseCase(executorService,fakeRepository);
        //Fin de Fixtures

        //Inicio de SUT
        boolean exists = useCase.execute(item.id).get();
        //Fin de SUT

        assertTrue(exists);
        usecase.clear();
    }

    //...

}

Fases

Toda prueba puede presentar las siguientes fases:

  1. Configuración (Setup): Preparación de los recursos y condiciones iniciales necesarios para la prueba, como puede ser la creación de objetos o la configuración del entorno. Aquí se crean y configuran los accesorios de prueba (Fixtures).
  2. Ejecución (Exercise): Ejecución del sistema bajo prueba (SUT).
  3. Verificación (Verify): Comprobación del comportamiento o resultado de la ejecución.
  4. Limpieza (Teardown): Liberación de recursos o restauración del entorno a su estado original, garantizando que la prueba no tenga efectos secundarios que puedan afectar otras pruebas.

Usando sólo métodos de prueba

Cada método de prueba crea o delega la creación de sus propios accesorios de prueba (Fixtures) y es independiente de las otras pruebas. Estos métodos de prueba deben incluir, en la medida de lo posible, solo las particularidades necesarias para su propósito.

Ventajas Desventajas
- Pruebas independientes: Cada prueba no depende de las otras por lo que no causarán problemas. - Código repetitivo: Es evidente la cantidad de código repetitivo producto de la creación o configuración de los accesorios.
- Pruebas lentas: Como cada prueba crea y configura sus accesorios hace que sea lenta la ejecución de la clase de prueba.

En el siguiente ejemplo, la ejecución de las pruebas no presenta problemas, pero se puede mejorar debido a la evidente cantidad de código repetitivo.

public class CheckItemUseCaseTest {

    @Test
    public void execute_withValidID_itemExists() throws Exception {
        //Fase de Configuración        
        Item item = createSampleItem();
        ExecutorService executorService = TestExecutorServiceFactory.create();
        Repository fakeRepository = FakeRepositoryFactory.createRepository();
        fakeRepository.saveItem(item).await();
        CheckItemUseCase useCase = new CheckItemUseCase(executorService,fakeRepository);
        //Fase de Ejecución
        boolean exists = useCase.execute(item.id).get();
        //Fase de Verificación
        assertTrue(exists);
        //Fase de Limpieza
        usecase.clear();
    }

    @Test
    public void execute_withInvalidID_itemNotExists() throws Exception {
        //Fase de Configuración
        ExecutorService executorService = TestExecutorServiceFactory.create();
        Repository fakeRepository = FakeRepositoryFactory.createRepository();
        CheckItemUseCase useCase = new CheckItemUseCase(executorService,fakeRepository);
        //Fase de Ejecución
        boolean exists = useCase.execute(666).get();
        //Fase de Verificación
        assertFalse(exists);
        //Fase de Limpieza
        usecase.clear();
    }

    //...

}

Usando la configuración y limpieza implícita

Consiste en agrupar las fases de configuración y limpieza de accesorios esenciales y comunes para varias pruebas, de modo que cada prueba llame a dichas fases de forma implícita durante su ejecución, permitiendo a la prueba en particular enfocarse en lo importante.

Ventajas Desventajas
- Mayor legibilidad: Al disponer de los accesorios necesarios pero irrelevantes en otro lugar, la prueba se vuelve más fácil de entender.
- Eliminación de duplicación de código: Gran parte de la duplicación de código producto de la fase de configuración y limpieza desaparece.
- Posibilidad de pruebas oscuras: Son pruebas que resultan difíciles de comprender a primera vista, y esto ocurre cuando los accesorios específicos (no comunes) de una prueba no son visibles para entender dicha prueba en particular.
- Posibilidad de pruebas frágiles: Ocurre cuando hay pruebas que realmente no requieren accesorios idénticos y cuando se modifica un accesorio común para adaptarse a una nueva prueba, varias otras pruebas fallan.

En el siguiente ejemplo, se utiliza correctamente la configuración y limpieza implícita. Esta versión usa JUnit 4.0 y cuenta con la etiqueta @Before para la fase de configuración y la etiqueta @After para la fase de limpieza. Por lo tanto, el método execute_withValidID_itemExists primero invocará a setup, luego ejecutará su contenido y, finalmente, llamará a tearDown, proceso que se repetirá en los demás pruebas.

public class CheckItemUseCaseTest {
    Repository fakeRepository;
    CheckItemUseCase useCase;

    @Before
    public void setUp() {
        //Fase de Configuración
        ExecutorService executorService = TestExecutorServiceFactory.create();
        fakeRepository = FakeRepositoryFactory.createRepository();
        useCase = new CheckItemUseCase(executorService,fakeRepository);
    }

    @After
    public void tearDown() {
        //Fase de Limpieza
        usecase.clear();
    }

    @Test
    public void execute_withValidID_itemExists() throws Exception {
        //Fase de Configuración
        Item item = createSampleItem();
        fakeRepository.saveItem(item).await();
        //Fase de Ejecución
        boolean exists = useCase.execute(item.id).get();
        //Fase de Verificación
        assertTrue(exists);
    }

    @Test
    public void execute_withInvalidID_itemNotExists() throws Exception {
        //Fase de Ejecución
        boolean exists = useCase.execute(666).get();
        //Fase de Verificación
        assertFalse(exists);
    }

    //...

}

Usando configuración y limpieza en conjunto de accesorios compartidos

Consiste en agrupar la fase de configuración y limpieza de aquellos accesorios compartidos esenciales que necesitan ejecutarse sólo una vez. De esta forma, la fase de configuración se ejecutará sólo una vez para todas las pruebas, y la fase de limpieza se realizará únicamente al finalizar la última prueba.

Ventajas Desventajas
- Pruebas más rápidas: Al configurar los accesorios compartidos una vez para todas las pruebas en lugar de hacerlo repetidamente para cada prueba, se ahorra tiempo y recursos, acelerando la ejecución de las pruebas.
- Mantiene la consistencia: Garantiza que todas las pruebas que comparten accesorios se ejecuten en un estado consistente y reduce errores de configuración.
- Mayor complejidad inicial: Configurar los accesorios compartidos puede requerir más esfuerzo y planificación inicial, especialmente cuando se tienen pruebas con diferentes requerimientos de accesorios.
- Potencial para efectos secundarios no deseados: Si no se maneja adecuadamente, el uso de accesorios compartidos puede llevar a efectos secundarios no deseados entre pruebas, lo que puede dificultar la detección y corrección de errores.
- Posibilidad de pruebas frágiles: Al compartir accesorios entre pruebas, existe la posibilidad de que una prueba afecte accidentalmente el estado de los accesorios de otra prueba, lo que puede dificultar el aislamiento de las pruebas y conducir a fallos.

En el siguiente ejemplo, se utiliza correctamente la configuración y limpieza de accesorios compartidos. Esta versión usa JUnit 4.0 y cuenta con la etiqueta @BeforeClass para la fase de configuración y la etiqueta @AfterClass para la fase de limpieza. Por lo tanto, al ejecutar CustomRepositoryTest, primero se ejecuta el método setupClass una única vez, luego se ejecutan los demás métodos de prueba, y finalmente se llama una sola vez al método tearDownClass.

public class CustomRepositoryTest {
    static TestSimulatedServer simulatedServer;
    static TestJsonParser jsonService;
    static CustomRepository repository;

    @BeforeClass
    public static void setupClass() throws IOException {
        //Fase de Configuración
        simulatedServer = TestSimulatedServerFactory.create(MovieRemoteRxRepositoryTest.class);
        simulatedServer.start(1010);
        jsonService = TestJsonParserFactory.create(MovieRemoteRxRepositoryTest.class);
        ExecutorService executorService = TestExecutorServiceFactory.create();
        repository = createRepository(executorService,simulatedServer.url());
    }

    @AfterClass
    public static void tearDownClass() throws IOException {
        //Fase de Limpieza
        simulatedServer.shutdown();
    }

    @Test
    public void getItems_whenHttpIsOK_returnList() throws Exception {
        //Fase de Configuración
        int anyPage = 1;
        String fileName = "upcoming_list.json";
        simulatedServer.enqueueFrom(fileName, HttpURLConnection.HTTP_OK);
        Items expectedList = jsonService.from(fileName, Items.class);
        //Fase de Ejecución
        List<Item> actualList = repository.getItems(anyPage).get().list;        
        //Fase de Verificación
        assertEquals(expectedList.results.size(), actualList.size());
    }

    //Aqui la Fase de Verificación se realiza en: "(expected = RemoteError.ServiceUnavailable.class)"
    @Test(expected = RemoteError.ServiceUnavailable.class)
    public void getItems_whenHttpUnavailable_throwServiceUnavailable() throws Exception {
        //Fase de Configuración
        int anyPage = 1;
        simulatedServer.enqueueEmpty(HttpURLConnection.HTTP_UNAVAILABLE);
        //Fase de Ejecución
        repository.geItems(anyPage).get();
    }

    //Aqui la Fase de Verificación se realiza en: "(expected = RemoteError.Unauthorised.class)"
    @Test(expected = RemoteError.Unauthorised.class)
    public void getItems_whenHttpUnauthorized_throwUnauthorised() throws Exception {
        //Fase de Configuración
        int anyPage = 1;
        simulatedServer.enqueueEmpty(HttpURLConnection.HTTP_UNAUTHORIZED);
        //Fase de Ejecución
        repository.getItems(anyPage).get();
    }

    //...

}

Tipos de pruebas de software

Aunque todas las pruebas son iguales desde un punto de vista arquitectónico, se pueden distinguir diferentes tipos según el contexto:

Pruebas Descripción
Unitarias Verificación individual de métodos o funciones de clases, componentes o módulos que usa el software.
Integración Verificación en conjunto de los diferentes módulos o servicios que usa el software.
Funcionales Verificación de los requisitos empresariales, esperando solo el resultado de una acción y dejando de lado los estados intermedios necesarios para completar dicha acción.
Integrales Verificación de los diferentes flujos de usuario, para que funcionen según lo previsto, esto se realiza replicando el comportamiento del usuario con el sistema.
Aceptación Verificación de satisfacción de los requisitos empresariales ejecutando toda la aplicación durante las pruebas, son pruebas que simulan el comportamiento de usuario e incluso miden el rendimiento.
Rendimiento Verificación del rendimiento del sistema con una carga de trabajo determinada para medir su capacidad de respuesta.
Humo Verificación de las principales funciones del sistema para garantizar su funcionamiento según lo previsto a través de pruebas sencillas y rápidas.

Tipos de dobles de prueba

Un doble de prueba es una versión de una clase diseñada específicamente para pruebas. Está destinado a reemplazar la versión real de una clase en las pruebas.

Tipo Descripción
Fake Dispone de una implementación funcional para satisfacer solo a la prueba.
Mock Emula el comportamiento y puede pasar o fallar dependiendo de si sus métodos fueron llamados correctamente.
Stub No incluye lógica y solo retorna lo que se ha programado.
Dummy Se necesita solo pasar pero no para ser usado, generalmente como parámetro.
Spy Recaba información adicional, como el número de veces que se llamó a un método en específico.

Desarrollo Guiado por Pruebas (TDD)

Definitivamente, al hablar de pruebas de software, el concepto de TDD se hace presente. TDD es una escuela de pensamiento de programación que se basa en escribir primero las pruebas y luego el código para satisfacer esas pruebas, por lo que es común que la primera vez que se ejecuta una prueba falle porque el código necesario para pasar la prueba aún no ha sido escrito.

Representación
image
Este desarrollo continuo y repetitivo garantiza que el código este siempre probado y mantenga la calidad a lo largo del tiempo.
Clone this wiki locally