PHPUnit. "¿Cómo pruebo mi maldito controlador?", O prueba de dudas

Hola Habr

imagen

Sí, esta es otra publicación sobre el tema de las pruebas. Parece que aquí ya es posible discutir? Todos los que lo necesitan, escriben exámenes, quienes no lo necesitan, no escriben, ¡todos están felices! El hecho es que la mayoría de las publicaciones sobre pruebas unitarias tienen ... cómo ofender a nadie ... ¡ejemplos idiotas! No, de verdad! Hoy intentaré arreglarlo. Pido gato.

Por lo tanto, buscar rápidamente en Google sobre el tema de las pruebas encuentra muchos artículos, que en su mayoría se dividen en dos categorías:

1) La felicidad de un redactor. Primero vemos una larga introducción, luego la historia de las pruebas unitarias en la Antigua Rusia, luego diez trucos de vida con pruebas, y al final un ejemplo. Con pruebas de código como esta:

<?php class Calculator { public function plus($a, $b) { return $a + $b; } } 

Y no estoy bromeando en este momento. Realmente vi artículos con una "calculadora" como guía de estudio. Sí, sí, entiendo que para empezar es necesario simplificar todo, abstracciones, de ida y vuelta ... ¡Pero aquí es donde todo termina! Y luego terminar el búho, como dicen

2) Ejemplos excesivamente sofisticados. Y escribamos una prueba, y métala en Gitlab CI, y luego la repararemos automáticamente si la prueba pasa, y aplicaremos Infección PHP a las pruebas, pero conectaremos todo a Hudson. Y así sucesivamente en ese estilo. Parece ser útil, pero parece que no es lo que estás buscando. Pero solo desea aumentar ligeramente la estabilidad de su proyecto. Y todas estas continuidades, bueno, no todas a la vez.

Como resultado, la gente duda: "¿Pero lo necesito?" Yo, a su vez, quiero tratar de explicar más claramente sobre las pruebas. Y haga una reserva de inmediato: soy desarrollador, no soy probador. Estoy seguro de que yo mismo no sé mucho, y mi primera palabra en mi vida no fue la palabra "mok". ¡Nunca he trabajado en TDD! Pero estoy seguro de que incluso mi nivel actual de habilidades me ha permitido cubrir varios proyectos con pruebas, y estas mismas pruebas ya han detectado una docena de errores. Y si me ayudó, entonces podría ayudar a alguien más. Algunos errores atrapados serían difíciles de atrapar manualmente.

Para comenzar, un breve programa educativo en el formato de preguntas y respuestas:

P: ¿Tengo que usar algún tipo de marco? ¿Qué pasa si tengo Yii? ¿Qué pasa si Kohana? ¿Qué pasa si% one_more_framework_name%?
R: No, PHPUnit es un marco de prueba independiente, incluso puede atornillarlo al código heredado en un marco de fabricación propia.

P: Y ahora paso rápidamente por el sitio con mis manos, y es normal. ¿Por qué lo necesito?
R: La "ejecución" de varias docenas de pruebas dura varios segundos. Las pruebas automáticas siempre son más rápidas que las manuales, y con pruebas de alta calidad también es más confiable, ya que cubre todos los escenarios.

P: Tengo un código heredado con funciones de 2000 líneas. ¿Puedo probar esto?
A: sí y no. En teoría, sí, cualquier código puede ser cubierto con una prueba. En la práctica, el código debe escribirse con una base para futuras pruebas. Una función de línea 2000 tendrá demasiadas dependencias, ramas, casos de borde. Puede resultar que lo cubra todo al final, pero lo más probable es que te lleve un tiempo inaceptablemente largo. Cuanto mejor sea el código, más fácil será probarlo. Cuanto mejor se respete la responsabilidad individual, más fáciles serán las pruebas. Para probar los proyectos antiguos con mayor frecuencia, primero debe refactorizarlos fríamente.

imagen

P: Tengo métodos (funciones) muy simples, ¿qué hay para probar? ¡Todo es confiable allí, no hay margen de error!
R: Debe entenderse que no prueba la implementación correcta de la función (si no tiene TDD), simplemente "arregla" su estado actual de trabajo. En el futuro, cuando necesite cambiarlo, puede determinar rápidamente si rompió su comportamiento utilizando la prueba. Ejemplo: hay una función que valida el correo electrónico. Ella lo hace un habitual.

 function isValid($email) { $regex = "very_complex_regex_here"; if (is_array($email)) { $result = true; foreach ($email as $item) { if (preg_match($regex, $item) === 0) { $result = false; } } } else { $result = preg_match($regex, $emai) ==! 0; } return $result; } 

Todo su código espera que si pasa un correo electrónico válido a esta función, se devolverá verdadero. Una serie de correos electrónicos válidos también es cierto. Una matriz con al menos una dirección de correo electrónico no válida es falsa. Bueno, etc., el código es claro. Pero llegó el día y decidiste reemplazar la monstruosa temporada regular con una API externa. Pero, ¿cómo garantizar que la función reescrita no haya cambiado el principio de funcionamiento? ¿De repente no maneja bien la matriz? ¿O volverá no booleano? Y las pruebas pueden mantener esto bajo control. Una prueba bien escrita indicará inmediatamente un comportamiento de la función diferente al esperado.

P: ¿Cuándo comenzaré a ver algo de sentido en las pruebas?
R: En primer lugar, tan pronto como cubra una parte significativa del código. Cuanto más cercana sea la cobertura al 100%, más confiables serán las pruebas. En segundo lugar, tan pronto como tenga que hacer cambios globales, o cambios en la parte compleja del código. Las pruebas pueden detectar problemas que pueden perderse fácilmente manualmente (casos límite). En tercer lugar, al escribir las pruebas ellos mismos! A menudo hay una situación en la que escribir una prueba revela fallas en el código que no son visibles a primera vista.

P: Bueno, tengo un sitio web en laravel. El sitio no es una función, es una montaña de código de mierda. ¿Cómo probar aquí?
A: Esto es lo que se discutirá más adelante. En resumen: probamos por separado los métodos de los controladores, separamos el middleware, separamos los servicios, etc.

Una de las ideas de las pruebas unitarias es aislar la sección de código probada. Cuanto menos código pruebe con una prueba, mejor. Veamos un ejemplo lo más cercano posible a la vida real:

 <?php class Controller { public function __construct($userService, $emailService) { $this->userService = $userService; $this->emailService = $emailService; } public function login($request) { if (empty($request->login) || empty($request->password)) { return "Auth error"; } $password = $this->userService->getPasswordFor($request->login); if (empty($password)) { return "Auth error - no password"; } if ($password !== $request->password) { return "Incorrect password"; } $this->emailService->sendEmail($request->login); return "Success"; } } // .... /* somewhere in project core */ $controller = new Controller($userService, $emailService); $controller->login($request); 

Este es un método muy típico para iniciar sesión en el sistema en pequeños proyectos. Todo lo que esperamos son los mensajes de error correctos y el correo electrónico enviado en caso de un inicio de sesión exitoso. ¿Cómo probar este método? Primero, necesita identificar dependencias externas. En nuestro caso, hay dos de ellos: $ userService y $ emailService. Se pasan a través del constructor de clase, lo que facilita enormemente nuestra tarea. Pero, como se mencionó anteriormente, cuanto menos código probamos en una pasada, mejor.

La emulación, sustitución de objetos se llama mokanem (del inglés. Mock object, literalmente: "object-parody"). Nadie se molesta en escribir tales objetos manualmente, pero todo ya se ha inventado antes que nosotros, por lo que una biblioteca tan maravillosa como Mockery viene al rescate. Creemos mokas para los servicios.

 $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); 

Ahora cree el objeto $ request. Para comenzar, probaremos la lógica de verificar los campos de inicio de sesión y contraseña. Queremos asegurarnos de que si no hay ninguno, nuestro método manejará correctamente este caso y devolverá el mensaje deseado (!).

 function testEmptyLogin() { $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); $controller = new Controller($userService, $emailService); $request = (object) []; $result = $controller->login($request); } 

Nada complicado, ¿verdad? Creamos stubs para los parámetros de clase necesarios, creamos una instancia de la clase deseada y "extrajimos" el método deseado, pasando una solicitud deliberadamente incorrecta. Tengo una respuesta ¿Pero cómo comprobarlo ahora? Esta es la parte más importante de la prueba: la llamada afirmación. PHPUnit tiene docenas de aserciones listas para usar . Solo usa uno de ellos.

 function testEmptyLogin() { $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); $controller = new Controller($userService, $emailService); $request = (object) []; $result = $controller->login($request); // vv assertion here! vv $this->assertEquals("Auth error", $result); } 

Esta prueba garantiza lo siguiente: si el argumento de inicio de sesión llega al objeto del método que no tiene el campo de inicio de sesión o contraseña, el método devolverá la cadena "Error de autenticación". Eso, en general, es todo. Tan simple, pero tan útil, porque ahora podemos editar el método de inicio de sesión sin temor a romper algo. Nuestro frontend puede estar seguro de que si sucede algo, recibirá un error de este tipo. Y si alguien interrumpe este comportamiento (por ejemplo, decide cambiar el texto del error), ¡la prueba lo indicará de inmediato! Agregamos los cheques restantes para cubrir tantos escenarios posibles como sea posible.

 function testEmptyPassword() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '' $userService->shouldReceive('getPasswordFor')->andReturn(''); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Auth error - no password", $result); } function testUncorrectPassword() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '4321' $userService->shouldReceive('getPasswordFor')->andReturn('4321'); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Incorrect password", $result); } function testSuccessfullLogin() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '1234' $userService->shouldReceive('getPasswordFor')->andReturn('1234'); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Success", $result); } 

Observe los métodos shouldReceive y andReturn? Nos permiten crear métodos en trozos que devuelven solo lo que necesitamos. ¿Necesita probar el error de contraseña incorrecta? Escribimos un stub $ userService que siempre devuelve la contraseña incorrecta. Y eso es todo.

Y qué hay de las dependencias, preguntas. Luego los "ahogamos", ¿y si se rompen? Pero esto es exactamente para lo que es la máxima cobertura de código con pruebas. No verificaremos el funcionamiento de estos servicios en el contexto del inicio de sesión; lo probaremos con la esperanza de que funcionen correctamente. Y luego escribimos las mismas pruebas aisladas para estos servicios. Y luego prueba sus dependencias. Y así sucesivamente. Como resultado, cada prueba individual garantiza solo el funcionamiento correcto de un pequeño fragmento de código, siempre que todas sus dependencias funcionen correctamente. Y dado que todas las dependencias también están cubiertas por pruebas, su funcionamiento correcto también está garantizado. Como resultado, cualquier cambio en el sistema que rompa la lógica del trabajo de incluso el código más pequeño aparecerá inmediatamente en una prueba en particular. Cómo ejecutar específicamente la ejecución de prueba: no lo diré, la documentación en PHPUnit es bastante buena. Y en Laravel, por ejemplo, es suficiente ejecutar vendor / bin / phpunit desde la raíz del proyecto para ver un mensaje como este

imagen - Todas las pruebas fueron exitosas. O algo como esto

imagen Una de las siete afirmaciones falló.

"Esto, por supuesto, es genial, pero ¿qué pasa con esto que no puedo tener en mis manos?", Preguntas. Y imaginemos el siguiente código para esto

 <?php function getInfo($infoApi, $userName) { $response = $infoApi->getInfo($userName); if ($response->status === "API Error") { return null; } return $response->result; } // ... somewhere in system $api = new ExternalApi(); $info = getInfo($api, 'John'); if ($info === null) { die('Api is down'); } echo $info; 

Vemos un modelo simplificado de trabajo con una API externa. La función utiliza alguna clase para trabajar con la API y, en caso de error, devuelve nulo. Si, al usar esta función, quedamos nulos, deberíamos "aumentar el pánico" (enviar un mensaje a la holgura, o enviar un correo electrónico al desarrollador, o arrojar un error en la kibana. Sí, un montón de opciones). Todo parece ser simple, ¿verdad? Pero imagine que después de algún tiempo otro desarrollador decidió "arreglar" esta función. Decidió que regresar nulo es el siglo pasado, y debería lanzar una excepción.

 function getInfo($infoApi, $userName): string { $response = $infoApi->getInfo($userName); if ($response->status === "API Error") { throw new ApiException($response); } return $response->result; } 

¡E incluso reescribió todas las secciones del código donde se llamó esta función! Todos menos uno. El lo extrañaba. Distraído, cansado, simplemente equivocado, pero nunca se sabe. El hecho es que una pieza de código todavía está esperando el viejo comportamiento de la función. Y PHP no es Java para nosotros: no obtendremos un error de compilación debido a que la función de lanzamiento no está envuelta en try-catch. Como resultado, en uno de los 100 escenarios para usar el sitio, en el caso de un bloqueo de API, no recibiremos un mensaje del sistema. Además, con las pruebas manuales, lo más probable es que no capturemos esta versión del evento. La API es externa, no depende de nosotros, funciona bien, y lo más probable es que no la tengamos en caso de falla de la API y manejo incorrecto de excepciones. Pero si tenemos pruebas, captarán este caso muy bien, porque la clase ExternalApi está "amortiguada" en varias pruebas, y emula tanto el comportamiento normal como el bloqueo. Y la próxima prueba caerá

 function testApiFail() { $api = Mockery::mock('api'); $api->shouldReceive('getInfo')->andReturn((object) [ 'status' => 'API Error' ]); $result = getInfo($api, 'name'); $this->assertNull($result); } 

Esta información es realmente suficiente. Si no tienes fideos heredados, después de 20-30 minutos puedes escribir tu primera prueba. Y unas semanas más tarde, para aprender algo nuevo, genial, vuelva a los comentarios en esta publicación y escriba qué autor el govnokoder no conoce% framework_name%, y escribe malas pruebas, pero debe hacer% this_way%. Y estaré muy feliz en ese caso. Esto significará que mi objetivo se ha logrado: ¡alguien más descubrió las pruebas por sí mismo y aumentó un poco el nivel general de profesionalismo en nuestro campo!

La crítica razonada es bienvenida.

Source: https://habr.com/ru/post/485418/


All Articles