Como estoy volviendo a programar sobre mi motor y he eliminado un montón de código, ahora tengo que repetir algunas clases y añadir otras. He estado mirando algunos paradigmas de programación y hay uno que llamó poderosamente mi atención que es la programacion orientada a test o “test driven development”.
Hace un tiempo publiqué una entrada sobre las bondades de los test unitarios, pero la verdad es que cuando profundizas en ellos, te acabas preguntando indefectiblemente: “para que quiero hacer test sobre un código que ya esta acabado?” y tambien uno se pregunta “si ya se como funciona ese metodo (por que yo mismo lo he programado), por que voy a probarlo?”
También hay gente que se pregunta si no es una herramienta de calidad usada por programadores. He invertido algún tiempo en leer cosas sobre el tema y puedo decir que no. TDD es una herramienta que abarata costes de producción porque reduce los bugs y sobretodo el tiempo que tardamos en encontrarlos. Pero ademas sirve para entender el código. Nunca te has bajado un código que no entiendes? Sabes que si lo tocas en el punto A vas a romper algo en el punto B (donde B puede ser cualquier lugar). Una manera de ahcer cambios en el código es escribir test unitarios para ver si realmente entendemos lo que hacemos. Si falla es que no lo entendemos. Si sale verde es que entendemos esa pieza de código.
En TDD debemos diseñar, escribir los tests (testear) y despues desarrollar. Los pasos serian:
- Escribimos el test case unitario
- Lanzamos los test unitarios escritos hasta ahora
- Si sale verde (todos los test OK), no hacemos nada más. Nuestro trabajo ha acabado a menos que salgan nuevos requisitos
- Si sale rojo aplicamos la solución más sencilla que se nos ocurra y nos lleve al verde.
- Volvemos al punto 2.
Es importante señalar que en este proceso solo podemos estar haciendo una de estas 3 cosas: programar tests unitarios nuevos, programar funciones que satisfagan los test unitarios, haciendo refactoring para reducir duplicidad de código. Este último punto se da por la regla de programar siempre la solución más sencilla. Recuerda que cada vez que tocamos código debemos lanzar otra vez nuestro conjunto de test unitarios.
Un ejemplo: imaginemos que tenemos que hacer parte de una calculadora donde entra un string con una operación matemática y tu devuelves un int que representa el resultado de la operacion o lanzas una excepción en caso de error. Es válido estas operaciones:
“1 + 2” y devolvemos 3
“5 - 3” y devolvemos 2
“3” y devolvemos 3
“” y devolvemos 0
Este ejemplo lo he sacado de aqui: http://sajdak.eu/trainings/agile-cpp-developer/google-test/first-and-easy-but-real-test/
Antes de seguir leyendo, intenta pensar en como programarias una función asi:
Lo primero que hacemos es crear uno de los test cases unitarios
TEST(text_calculator,001_empty_string_returns_zero)
{
CText_calculator tc;
ASSERT_EQ(0, tc.calculate(""));
}
Ejecutamos los test y ni siquiera compilan, por que no existe la funcion. Pero ya hemos ejecutado y cada ejecución nos puede servir de barrera para “cambiar de sombrero” de programador de aplicacion a programador de test. Asi solo tocamos una cosa cada vez. Recuerda que la idea es implementar la idea más sencilla que se nos ocurra.
#pragma once
#include "string"
class CText_calculator
{
public:
int calculate(const std::string& op)
{
return 0;
}
CText_calculator(void){}
~CText_calculator(void){}
};
Volvemos a ejecutar los test cases y … “verde!!!”
Ya cumplimos el primer requisito. Vamos a escribir otro test con el segundo requisito
TEST(text_calculator, cadena_vacia_devuelve_cero)
{
ASSERT_EQUALS(text_calculator(“3”), 3);
}
Ejecutamos y evidentemente los tests fallan.
Running main() from gtest_main.cc
[==========] Running 2 tests from 1 test case.
[----------] Global test environment set-up.
[----------] 2 tests from text_calculator
[ RUN ] text_calculator.001_empty_string_returns_zero
[ OK ] text_calculator.001_empty_string_returns_zero (0 ms)
[ RUN ] text_calculator.002_literal_3_returns_3
c:\directo\mypgp\blog_to_tes\text_calculator_test\text_calculator_test\test_case
s.cpp(13): error: Value of: tc.calculate("3")
Actual: 0
Expected: 3
[ FAILED ] text_calculator.002_literal_3_returns_3 (0 ms)
[----------] 2 tests from text_calculator (0 ms total)
[----------] Global test environment tear-down
[==========] 2 tests from 1 test case ran. (0 ms total)
[ PASSED ] 1 test.
[ FAILED ] 1 test, listed below:
[ FAILED ] text_calculator.002_literal_3_returns_3
1 FAILED TEST
Presione una tecla para continuar . . .
Falla cocretamente el test del 3. Vamos a meter unos if
int calculate(const std::string& op)
{
if(op == "")
return 0;
if(op == "3")
return 3;
}
Al ejecutar, todos los test van bien. Pero como el requisito no es meterle “3” sino cualquier numero, vamos a crear un test mas amplio:
TEST(text_calculator,003_literal_always_return_the_number_that_represents)
{
CText_calculator tc;
srand(time(NULL));
int generated = rand() % 1000 + 1;
std::stringstream out;
out << generated;
ASSERT_EQ(generated, tc.calculate(out.str()));
}
Ejcutamos el test y falla. Además vemos que con un if no vamos a ninuna parte, asi que modificamos el código de otra manera:
#pragma once
#include "string"
#include "sstream"
class CText_calculator
{
public:
int calculate(const std::string& op)
{
if(op.size() == 0)
return 0;
int result;
std::stringstream(op) >> result;
return result;
}
CText_calculator(void){}
~CText_calculator(void){}
};
Ahora volvemos a ejecutar y todo va bien, da igual el número que le metamos. Vamos ahora con las operaciones de dos operandos. Lo primero, escribir el test correspondiente:
TEST(text_calculator,004_3_plus_5_always_returns_6)
{
CText_calculator tc;
ASSERT_EQ(8, tc.calculate("3 + 5"));
}
Para resolverlo puedo se me ocurre que primero podria mirar si hay un ‘+’ y entonces ya sabria que es una operacion de dos operandos:
#pragma once
#include "string"
#include "sstream"
class CText_calculator
{
public:
int calculate(const std::string& op)
{
if(op.size() == 0)
return 0;
int result;
std::stringstream ss(op);
ss >> result;
size_t operator_pos = op.find("+");
if(operator_pos == std::string::npos)
return result;
std::string second_op(op.substr(operator_pos + 1));
int result2 ;
std::stringstream ss2(second_op);
ss2 >> result2;
return result2 + result;
}
CText_calculator(void){}
~CText_calculator(void){}
Los test pasan perfectamente pero veo varias cosas. La primera que el codigo de convertir strings a int se repite, y el codigo repetido casi siempre hay que evitarlo. Vamos a hacer refactoring. Este es otro gran uso del TDD. Si primero, antes de hacer refactor, escribimos los test y nos aseguramos que pasan, despues solo tenemos que ejecutar los test cases despues de cada cambio del refactoring para darnos cuenta si falla algo. El resultado de separa la función de pasar de string a numero es esta:
#pragma once
#include "string"
#include "sstream"
class CText_calculator
{
private:
int str_to_number(const std::string& str)
{
int result;
std::stringstream ss(str);
ss >> result;
return result;
}
public:
int calculate(const std::string& op)
{
if(op.size() == 0)
return 0;
int number1 = str_to_number(op);
size_t operator_pos = op.find("+");
if(operator_pos == std::string::npos)
return number1;
std::string second_op(op.substr(operator_pos + 1));
int number2 = str_to_number(op.substr(operator_pos + 1));
return number1 + number2;
}
CText_calculator(void){}
~CText_calculator(void){}
};
Ahora ya puedo escribir otro caso de test:
TEST(text_calculator,004_5_minus_3_always_returns_2)
{
CText_calculator tc;
ASSERT_EQ(2, tc.calculate("5 - 3"));
}
Y al ver que falla aplicar los cambios: hay varios problemas que quiero solventar. Por un lado falla al reconocer el simbolo, y por otro lado puede que no acepte combinaciones raras tipo “4+”,
“+5”, “+”,”hfds+3”.
La primera parte se puede solucionar dividiento el string en tres partes: operando, simbolo, operando. Para ello uso un boost::tokenizer y cambio la manera de enfocar las operaciones:
#pragma once
#include "string"
#include "sstream"
#include "boost/tokenizer.hpp"
class CText_calculator
{
private:
int divide_string(const std::string& op,std::string& op1, std::string& simbolo, std::string& op2)
{
typedef boost::tokenizer > tokenizer;
boost::char_separator sep("-+");
tokenizer tokens(op,sep);
tokenizer::iterator tok_iter = tokens.begin();
op1 = *tok_iter++;
if(tok_iter != tokens.end())
op2 = *tok_iter;
else
return 1;
simbolo = op.substr(op1.size(), 1);
return 2;
}
int str_to_number(const std::string& str)
{
int result;
std::stringstream ss(str);
ss >> result;
return result;
}
public:
int calculate(const std::string& op)
{
if(op.size() == 0)
return 0;
std::string op1, op2, symbol;
int result = 0;
if ( result = divide_string(op, op1, symbol, op2) == 1)
return str_to_number(op1);
if (symbol == "+")
return str_to_number(op1) + str_to_number(op2);
if (symbol == "-")
return str_to_number(op1) - str_to_number(op2);
}
CText_calculator(void){}
~CText_calculator(void){}
};
Añadir las dos operaciones que faltan es trivial. Solo hay que añadir los dos simbolos que faltan a la lista de separator char y añadir los if correspondientes. Añado primero los test:
TEST(text_calculator,005_10_mult_10_always_returns_100)
{
CText_calculator tc;
ASSERT_EQ(100, tc.calculate("10 * 10"));
}
TEST(text_calculator,006_15_divided_3_always_returns_5)
{
CText_calculator tc;
ASSERT_EQ(5, tc.calculate(" 15 / 3 "));
}
Ejecuto y aplico cambios. Recuerda que después de decir que el cambio era trivial, he escrito los tests y los he ejecutado. Parece paranoico pero es lo mejor por muchas razones. Primero por que la próxima vez que alguien le meta mano al código, ya están hechos los tests. Segundo por que un mal click puede introducir un simbolo incorrecto y luego ese error trivial convertirse en un error oscuro y difícil de encontrar dentro una aplicación mas grande
El código final es este:
#pragma once
#include "string"
#include "sstream"
#include "boost/tokenizer.hpp"
class CText_calculator
{
private:
int divide_string(const std::string& op,std::string& op1, std::string& simbolo, std::string& op2)
{
typedef boost::tokenizer > tokenizer;
boost::char_separator sep("-+*/");
tokenizer tokens(op,sep);
tokenizer::iterator tok_iter = tokens.begin();
op1 = *tok_iter++;
if(tok_iter != tokens.end())
op2 = *tok_iter;
else
return 1;
simbolo = op.substr(op1.size(), 1);
return 2;
}
int str_to_number(const std::string& str)
{
int result;
std::stringstream ss(str);
ss >> result;
return result;
}
public:
int calculate(const std::string& op)
{
if(op.size() == 0)
return 0;
std::string op1, op2, symbol;
int result = 0;
if ( result = divide_string(op, op1, symbol, op2) == 1)
return str_to_number(op1);
if (symbol == "+")
return str_to_number(op1) + str_to_number(op2);
if (symbol == "-")
return str_to_number(op1) - str_to_number(op2);
if (symbol == "*")
return str_to_number(op1) * str_to_number(op2);
if (symbol == "/")
return str_to_number(op1) / str_to_number(op2);
}
CText_calculator(void){}
~CText_calculator(void){}
};
Todavia podriamos aplicar un refactor con todos esos “if” de la funcion publica, y para ello los test automáticos que hemos hecho nos ayudarian también. Si piensas que escribir los tests cases llevan mucho tiempo, piensa que en total no me ha llevado mas de 3 o cuatro minutos (son todo copy/paste). A cambio, la aplicación sale con un grado de madurez medio bueno, y eso que en realidad no he hecho todos los test que se me puedan ocurrir. Todavia quedarian por añadir casos algo más extremos como:
TEST(text_calculator,007_tests_with_wrong_values)
{
CText_calculator tc;
ASSERT_EQ(0, tc.calculate(" 1assadf / 3 "));
}
TEST(text_calculator,008_tests_with_wrong_values_2)
{
CText_calculator tc;
ASSERT_EQ(0, tc.calculate(" 15 / sadas3 "));
}
Y descubrir que este, por ejemplo, falla
TEST(text_calculator,009_tests_with_wrong_values_3)
{
CText_calculator tc;
ASSERT_EQ(0, tc.calculate(" 15 16/ 3 "));
}
Y deberia aplicar cambios, pero creo que a partir de ahi ya ves por donde va el tema.
Espero que este articulo te haya demostrado como el TDD puede ayudarte a escribir mejor código a la vez que acelera el ritmo de produccion al evitar que los bugs se escondan en tu código durante demasiado tiempo.
Si tienes alguna pregunta no dudes en postear.