Testbare Software und die Unterstützung durch Rust

Gespeichert von Erik Wegner am/um
Aufmacherbild

Am Beispiel einer einfachen Anwendung lässt sich eine Erfahrung aus dem Software-Entwurf gut zeigen. Weiterhin wird sichtbar, wie die verwendete Programmiersprache Rust unterstützt, gut und sicher zu entwickeln.

Aufgabe

Es gibt eine Ausgangstabelle, in jeder Zeile stehen zusammengehörige Werte. Jede Zeile soll so umgeformt werden, dass das Ergebnis als JavaScript/JSON-Code verwendet werden kann.

Lösungsansatz

Zur Beschreibung des Lösungsansatzes zerlege ich die Aufgabe in diese einzelnen Schritte:

  1. Zugriff auf die Zeilen der Datentabelle
  2. Transformation der Zeilen
  3. Bereitstellung des Ergebnisses für das nächste System

Damit die Komplexität überschaubar bleibt, verzichtet die Anwendung auf das Lesen der ODS-Datei. Die geringe Nutzungsfrequenz rechtfertigt den manuellen Vorgang, die Datei zu öffnen und die Daten über die Zwischenablage zu kopieren. Damit ist klar, dass die Anwendung die Daten über die Standardeingabe entgegennehmen soll.

Damit die Komplexität überschaubar bleibt, verzichtet die Anwendung auf das Lesen der ODS-Datei. Die geringe Nutzungsfrequenz rechtfertigt den manuellen Vorgang, die Datei zu öffnen und die Daten über die Zwischenablage zu kopieren. Damit ist klar, dass die Anwendung die Daten über die Standardeingabe entgegennehmen soll. Auch das Ergebnis kann auf der Standardausgabe angezeigt werden, die Menge der Daten wird im mittleren zweistelligen Bereich erwartet.

Projektstart

Es gibt verschiedene Wege, eine Rust-Umgebung einzurichten (Dokumentation). Ein besonders einfacher und eleganter Weg besteht in der Nutzung der devcontainer in Visual Studio Code. Ein Beispiel-Container für Rust steht unmittelbar zur Verfügung.

In meiner Umgebung wurde die Erweiterung rust analyzer nicht automatisch installiert, deshalb installiere ich rust-lang.rust-analyzer selbst. Eine weitere hilfreiche Erweiterung ist usernamehw.errorlens zur Darstellung der Fehlermeldungen innerhalb des Quelltextes.

In der Datei Cargo.toml werden nun diese Anpassungen vorgenommen:

Cargo.toml
[package]
name = "spreadsheet2json"
version = "1.0.0"
edition = "2021"

Die Edition 2021 stellt die aktuelle Version des Compilers ein.

Nun beginnt die Entwicklung in der Datei main.rs.

Lesen der Standardeingabe

Ein erster Test soll zeigen, wie die Daten über die Zwischenablage in der Standardeingabe angekommen. Dazu verwende ich diesen Quellcode:

main.rs
use std::io::{self, BufRead};

fn main() -> io::Result<()> {
let lines = io::stdin().lock().lines();
let mut user_input = String::new();
println!("Paste data. End with an empty line.");
for line in lines {
let last_input = line.unwrap();

// stop reading
if last_input.is_empty() {
break;
}

// add a new line once user_input starts storing user input
if !user_input.is_empty() {
user_input.push('\n');
}

// store user input
user_input.push_str(&last_input);
}

println!("\nResult \n{}", user_input);
// the lock is released after it goes out of scope
Ok(())
}

Ein Aufruf von cargo run startet den Compilervorgang und anschließend die Anwendung. Nun können die Zeilen aus LibreOffice Calc oder OpenOffice Calc kopiert und im Terminalfenster eingefügt werden. Es zeigt sich, dass die Spalten durch Tabulatorzeichen getrennt übergeben werden.

Transformation der Zeilen

Der nun folgende Prozess kommt vorläufig ohne manuelle Aufrufe der Anwendung aus. An dieser Stelle kann die Verarbeitung vollständig testgetrieben entwickelt werden. Etwas Trennung im Quellcode ist hilfreich, die Verarbeitung soll in der Datei handling.rs erfolgen.

handling.rs
pub(crate) fn handle_input(lines: &String) -> String {
todo!("Not implemented yet");
}

#[cfg(test)]
mod tests {
use crate::handling;

#[test]
fn it_produces_output() {
let input_value = String::from("A\tB\n123\t881881sds\n611\t9902");
let result = handling::handle_input(&input_value);
assert_eq!(
result,
String::from("\"123\":{\"d\":\"881881sds\"},\n\"611\":{\"d\":\"9902\"},\n")
);
}

#[test]
fn it_handles_error() {
let input_value = String::from("A\tB\n123\t881881sds\n5\n611\t9902");
let result = handling::handle_input(&input_value);
assert_eq!(result, String::from("Invalid row 3"));
}
}

In der ersten Interation ist bereits sichtbar, dass im Verarbeitungsteil keine Interaktion mit der Außenwelt erfolgt. Die Quelle der Eingabe und die Rückgabe der Ausgabe sind nicht Bestandteil dieses Moduls.

Nun muss dieses Modul geladen werden, indem in der main.rs die Verwendung durch mod handling; angemeldet wird. Ein Aufruf von cargo test führt nun die Tests aus:

   Compiling spreadsheet2json v1.0.0 (/workspaces/vscode-remote-try-rust)
warning: unused variable: `lines`
--> src/handling.rs:1:28
|
1 | pub(crate) fn handle_input(lines: &String) -> String {
| ^^^^^ help: if this is intentional, prefix it with an underscore: `_lines`
|
= note: `#[warn(unused_variables)]` on by default

warning: `spreadsheet2json` (bin "spreadsheet2json" test) generated 1 warning
Finished test [unoptimized + debuginfo] target(s) in 1.13s
Running unittests src/main.rs (target/debug/deps/spreadsheet2json-cab1fd06cf3feb19)

running 2 tests
test handling::tests::it_produces_output ... FAILED
test handling::tests::it_handles_error ... FAILED

failures:

---- handling::tests::it_produces_output stdout ----
thread 'handling::tests::it_produces_output' panicked at 'not yet implemented: Not implemented yet', src/handling.rs:2:5

---- handling::tests::it_handles_error stdout ----
thread 'handling::tests::it_handles_error' panicked at 'not yet implemented: Not implemented yet', src/handling.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
handling::tests::it_handles_error
handling::tests::it_produces_output

test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--bin spreadsheet2json'

Positiv-Test

handling.rs
fn handle_line(line: &str, row_num: i32) -> String {
let columns: Vec<&str> = line.split('\t').collect();
if columns.len() < 2 {
panic!("Invalid row {}", row_num);
}
format!("\"{}\":{{\"d\":\"{}\"}}", columns[0], columns[1])
}

pub(crate) fn handle_input(lines: &String) -> String {
let mut row_num = 1;
lines
.split('\n')
.skip(1)
.map(|line| {
row_num += 1;
handle_line(line, row_num)
})
.collect::<Vec<String>>()
.join(",\n") + ",\n"
}

Der erste Test ist nun grün.

Zwischenfazit

Die Anwendung wäre in diesem Zustand nutzbar, ein Fehler in den Eingabedaten bricht die Programmausführung ab. Doch das reicht mir nicht. Ich möchte auch die Fehlerbehandlung testen.

Negativ-Test spielend mit Rust Result

Jede Programmiersprache muss eine Möglichkeit anbieten, mit Fehlern umzugehen. Manche Sprachen wie Java verwenden sehr strenge Formen von Exceptions, die ein Aufrufer in jedem Fall behandeln muss. JavaScript verwendet Exceptions, es bleibt aber dem Aufrufer überlassen, die Fehler zu fangen. Rust (und auch Go) wählen den Weg, dass Funktionsaufrufe in ihrer Rückgabe transportieren, ob der Aufruf erfolgreich war. Und während Go leider leicht dafür sorgt, dass diese Fehlerbehandlung ignoriert werden kann (Details im Abschnitt Result<Metadata>), enthält Rust alle Bestandteile, Erfolg und Fehler sauber zu behandeln (siehe auch Video Rust on Rails (write code that never crashes)).

Dazu verwendet die Funktion handle_line nun Result als Rückgabewert:

handling.rs
fn handle_line(line: &str, row_num: i32) -> Result<String, String> {
let columns: Vec<&str> = line.split('\t').collect();
if columns.len() < 2 {
return Err(format!("Invalid row {}", row_num));
}
Ok(format!("\"{}\":{{\"d\":\"{}\"}}", columns[0], columns[1]))
}

pub(crate) fn handle_input(lines: &String) -> String {
let mut row_num = 1;
let (rows_ok, rows_with_errors): (Vec<_>, Vec<_>) = lines
.split('\n')
.skip(1)
.map(|line| {
row_num += 1;
handle_line(line, row_num)
})
.partition(|i| i.is_ok()); // Aufteilung der Ergebnisse je nach Erfolg

if !rows_with_errors.is_empty() {
return rows_with_errors
.into_iter()
.map(|i| i.err().unwrap())
.reduce(|accum, item| format!("{}\n{}", accum, item))
.unwrap();
}

rows_ok
.into_iter()
.map(|r| r.unwrap())
.collect::<Vec<String>>()
.join(",\n")
+ ",\n"
}

Damit wird auch der zweite Test für die Fehlerbehandlung grün.