Desarrollando la funcionalidad de la biblioteca con T.D.D.
Ahora que hemos extraído la lógica en src/lib.rs y dejado la recolección de argumentos y el manejo de errores en src/main.rs, es mucho más fácil escribir pruebas para la funcionalidad principal de nuestro código. Podemos llamar a las funciones directamente con varios argumentos y verificar los valores de retorno sin tener que llamar a nuestro binario desde la línea de comandos.
En esta sección, agregaremos la lógica de búsqueda al programa minigrep
utilizando el proceso de desarrollo impulsado por pruebas (TDD) con los
siguientes pasos:
- Escriba un test que falle y ejecútala para asegurarse de que falla por la razón que espera.
- Escribe o modifica solo el código suficiente para que el nuevo test pase.
- Refactoriza el código que acabas de agregar o cambiar y asegúrate de que los tests sigan pasando.
- ¡Repite desde el paso 1!
Aunque es solo una de las muchas formas de escribir software, TDD puede ayudar a impulsar el diseño del código. Escribir la prueba antes de escribir el código que hace que la prueba pase ayuda a mantener una alta cobertura de prueba durante todo el proceso.
Vamos a probar la implementación de la funcionalidad que realmente buscará el
string de consulta en el contenido del archivo y producirá una lista de líneas
que coincidan con la consulta. Agregaremos esta funcionalidad en una función
llamada search
.
Escribiendo un test fallido
Debido a que ya no los necesitamos, eliminemos las declaraciones println!
de
src/lib.rs y src/main.rs que usamos para verificar el comportamiento del
programa. Luego, en src/lib.rs, agregue un módulo tests
con una función de
prueba, como lo hicimos en Capítulo 11. La
función de prueba especifica el comportamiento que queremos que tenga la
función search
: tomará una consulta y el texto a buscar, y devolverá solo las
líneas del texto que contengan la consulta. El listado 12-15 muestra esta
prueba, que aún no se compilará.
Filename: src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
Listing 12-15: Creando un test fallido para la función
search
que deseamos tener
Este test busca el string "duct"
. El texto que estamos buscando son tres
líneas, solo una de las cuales contiene "duct"
(Tenga en cuenta que la barra
invertida después de la comilla doble de apertura le dice a Rust que no ponga
un carácter de nueva línea al comienzo del contenido de esta cadena literal).
Afirmamos que el valor devuelto de la función search
contiene solo la línea
que esperamos.
Aún no podemos ejecutar este test y verlo fallar porque el test ni siquiera se
compila: ¡la función search
aún no existe! De acuerdo con los principios de
TDD, agregaremos solo el código suficiente para que la prueba se compile y se
ejecute agregando una definición de la función search
que siempre devuelve
un vector vacío, como se muestra en el listado 12-16. Luego, la prueba debería
compilar y fallar porque un vector vacío no coincide con un vector que
contiene la línea "safe, fast, productive."
.
Filename: src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
Ok(())
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
Listing 12-16: Definiendo solo lo necesario de la función
search
para que nuestro test compile
Observa que necesitamos definir un lifetime explícito 'a
en la firma de
search
y usar ese lifetime con el argumento contents
y el valor de retorno.
Recuerde en Capítulo 10 que los parámetros de
lifetime especifican qué lifetime de argumento está conectado al lifetime del
valor de retorno. En este caso, indicamos que el vector devuelto debe contener
string slices que hagan referencia a slices del argumento contents
(en lugar
del argumento query
).
En otras palabras, le decimos a Rust que los datos devueltos por la función
search
vivirán tanto tiempo como los datos pasados a la función search
en
el argumento contents
. ¡Esto es importante! Los datos a los que hace
referencia un slice deben ser válidos para que la referencia sea válida; si el
compilador asume que estamos haciendo string slices de query
en lugar de
contents
, hará sus comprobaciones de seguridad incorrectamente.
Si olvidamos las anotaciones de lifetime y tratamos de compilar esta función, obtendremos este error:
$ cargo build
Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
--> src/lib.rs:28:51
|
28 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
|
28 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error
Rust no puede saber qué argumento de los dos necesitamos, por lo que debemos
decirle explícitamente. Debido a que contents
es el argumento que contiene
todo nuestro texto y queremos devolver las partes de ese texto que coincidan,
sabemos que contents
es el argumento que debe estar conectado al valor de
retorno usando la sintaxis de lifetime.
Otros lenguajes de programación no requieren que conectes argumentos a valores de retorno en la firma, pero esta práctica será más fácil con el tiempo. Quizás quiera comparar este ejemplo con la sección "Validando referencias con lifetimes" en el Capítulo 10.
Ahora ejecutemos el test:
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test
test tests::one_result ... FAILED
failures:
---- tests::one_result stdout ----
thread 'tests::one_result' panicked at src/lib.rs:44:9:
assertion `left == right` failed
left: ["safe, fast, productive."]
right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::one_result
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
¡Genial, el test falla, exactamente como esperábamos! ¡Vamos a hacer que el test pase!
Escribiendo código para pasar el test
Actualmente, nuestro test falla porque siempre devolvemos un vector vacío. Para
solucionar eso e implementar search
, nuestro programa debe seguir estos
pasos:
- Iterar a través de cada línea del contenido.
- Compruebe si la línea contiene nuestro string de consulta.
- Si es así, agréguelo a la lista de valores que estamos devolviendo.
- Si no lo hace, no haga nada.
- Devuelve la lista de resultados que coinciden.
Trabajaremos en cada paso, comenzando por iterar a través de las líneas.
Iterando a través de las líneas con el método lines
Rust tiene un método útil para manejar la iteración línea por línea de strings,
convenientemente llamado lines
, que funciona como se muestra en el listado
12-17. Tenga en cuenta que esto aún no se compilará.
Filename: src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
Ok(())
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
// do something with line
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
Listing 12-17: Iterando a través de cada línea en
contents
El método lines
devuelve un iterador. Hablaremos sobre los iteradores en
profundidad en Capítulo 13, pero recuerde que
vio esta forma de usar un iterador en Listado 3-5,
donde usamos un bucle for
con un iterador para ejecutar algún código en cada
elemento de una colección.
Buscando cada línea para la consulta
A continuación, necesitamos verificar si la línea contiene el string de
consulta. Afortunadamente, los strings tienen un método útil llamado contains
que hace esto por nosotros. Agregue una llamada al método contains
en la
función search
, como se muestra en el listado 12-18. Tenga en cuenta que esto
aún no se compilará.
Filename: src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
Ok(())
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
if line.contains(query) {
// do something with line
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
Listing 12-18: Agregando funcionalidad para verificar si
la línea contiene el string en query
En este punto, estamos construyendo funcionalidad. Para que compile, debemos devolver un valor del cuerpo como indicamos en la firma de la función.
Almacenando líneas coincidentes
Para terminar esta función, necesitamos una forma de almacenar las líneas
coincidentes que queremos devolver. Para eso, podemos hacer un vector mutable
antes del bucle for
y llamar al método push
para almacenar una line
en el
vector. Después del bucle for
, devolvemos el vector, como se muestra en el
listado 12-19.
Filename: src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
Ok(())
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
Listing 12-19: Almacenando las líneas que coinciden para poder devolverlas
Ahora la función search
debería devolver solo las líneas que contienen
query
, y nuestro test debería pasar. Ejecutemos el test:
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test
test tests::one_result ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Nuestro test pasó, así que sabemos que funciona. ¡Genial!
En este punto, podríamos considerar oportunidades para refactorizar la
implementación de la función search
mientras mantenemos las pruebas para
mantener la misma funcionalidad. El código en la función search
no es tan
malo, pero no aprovecha algunas características útiles de los iteradores.
Volveremos a este ejemplo en Capítulo 13, donde
exploraremos los iteradores en detalle y veremos cómo mejorarlo.
Usando la función search
en la función run
Ahora que la función search
funciona y está probada, necesitamos llamar a
search
desde nuestra función run
. Necesitamos pasar el valor de
config.query
y el contents
que run
lee del archivo a la función search
.
Luego, run
imprimirá cada línea devuelta por search
:
Filename: src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
for line in search(&config.query, &contents) {
println!("{line}");
}
Ok(())
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
Todavía estamos usando un bucle for
para devolver cada línea de search
e
imprimirla.
Ahora todo el programa debería funcionar. Probémoslo con una palabra que debería devolver exactamente una línea del poema de Emily Dickinson, "frog":
$ cargo run -- frog poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
Running `target/debug/minigrep frog poem.txt`
How public, like a frog
¡Funciona! Ahora intentemos que coincida con varias líneas, como "body":
$ cargo run -- body poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!
Y finalmente, asegurémonos de que no obtengamos ninguna línea cuando buscamos una palabra que no está en el poema, como "monomorphization":
$ cargo run -- monomorphization poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep monomorphization poem.txt`
¡Excelente! Hemos construido nuestra propia versión de una herramienta clásica y hemos aprendido mucho sobre cómo estructurar aplicaciones. También hemos aprendido un poco sobre input y output de archivos, lifetimes, testing y análisis de líneas de comandos.
Para completar nuestro proyecto, demostraremos brevemente cómo trabajar con variables de entorno y cómo imprimir en el error estándar, ambas son útiles cuando se escriben programas de línea de comandos.