Bonjour, Habr! Je vous présente la traduction de l'entrée "# [test] en 2018" sur le blog de John Renner, qui se trouve
ici .
Récemment, j'ai travaillé sur l'implémentation d'
eRFC pour des cadres de test personnalisés pour Rust. En étudiant la base de code du compilateur, j'ai étudié les principes internes des tests dans Rust et j'ai réalisé qu'il serait intéressant de partager cela.
Attribut # [test]
Aujourd'hui, les programmeurs Rust comptent sur l'attribut intégré
#[test]
. Tout ce que vous avez à faire est de marquer la fonction comme test et d'activer certaines vérifications:
#[test] fn my_test() { assert!(2+2 == 4); }
Lorsque ce programme est compilé à l'aide des commandes
rustc --test
ou
cargo test
, il crée un fichier exécutable qui peut exécuter cette fonction et toute autre fonction de test. Cette méthode de test vous permet de garder organiquement des tests proches du code. Vous pouvez même mettre des tests à l'intérieur de modules privés:
mod my_priv_mod { fn my_priv_func() -> bool {} #[test] fn test_priv_func() { assert!(my_priv_func()); } }
Ainsi, les entités privées peuvent être facilement testées sans utiliser d'outils de test externes. C'est la clé des tests ergonomiques de Rust. Sémantiquement, cependant, c'est plutôt étrange. Comment la fonction
main
appelle-t-elle ces tests s'ils ne sont pas visibles (
note du traducteur : je vous le rappelle, privés - déclarés sans utiliser le mot clé
pub
- sont protégés par encapsulation depuis un accès extérieur)? Que fait exactement
rustc --test
?
#[test]
implémenté comme une conversion de syntaxe dans la
libsyntax
compilateur libsyntax. Il s'agit essentiellement d'une macro fantaisie qui réécrit notre caisse en 3 étapes:
Étape 1: réexporter
Comme mentionné précédemment, les tests peuvent exister à l'intérieur des modules privés, nous avons donc besoin d'un moyen de les exposer à la fonction
main
sans casser le code existant. À cette fin,
libsyntax
crée des modules locaux appelés __test_reexports
qui __test_reexports
récursivement __test_reexports
tests . Cette divulgation traduit l'exemple ci-dessus en:
mod my_priv_mod { fn my_priv_func() -> bool {} fn test_priv_func() { assert!(my_priv_func()); } pub mod __test_reexports { pub use super::test_priv_func; } }
Notre test est maintenant disponible sous le nom
my_priv_mod::__test_reexports::test_priv_func
. Pour les modules imbriqués,
__test_reexports
modules contenant les tests, donc le test
a::b::my_test
devient
a::__test_reexports::b::__test_reexports::my_test
. Ce processus semble assez sûr jusqu'à présent, mais que se passe-t-il s'il existe un module
__test_reexports
existant? Réponse:
rien .
Pour expliquer, nous devons comprendre
comment l'AST représente les identificateurs . Le nom de chaque fonction, variable, module, etc. stocké non pas comme une chaîne, mais plutôt comme un
symbole opaque, qui est essentiellement un numéro d'identification pour chaque identifiant. Le compilateur stocke une table de hachage distincte, ce qui nous permet de restaurer le nom lisible du symbole si nécessaire (par exemple, lors de l'impression d'une erreur de syntaxe). Lorsque le compilateur crée le module
__test_reexports
, il génère un nouveau symbole pour l'identificateur.Par conséquent, bien que les
__test_reexports
générés par le compilateur puissent être du même nom avec votre module générique, il n'utilisera pas son symbole. Cette technique empêche les collisions de noms lors de la génération de code et est la base de l'hygiène du système macro Rust.
Étape 2: génération du cerclage
Maintenant que nos tests sont accessibles depuis la racine de notre caisse, nous devons faire quelque chose avec eux.
libsyntax
génère un tel module:
pub mod __test { extern crate test; const TESTS: &'static [self::test::TestDescAndFn] = &[]; #[main] pub fn main() { self::test::test_static_main(TESTS); } }
Bien que cette conversion soit simple, elle nous donne beaucoup d'informations sur la façon dont les tests sont réellement effectués. Les tests sont collectés dans un tableau et transmis au
test_static_main
test, appelé
test_static_main
. Nous reviendrons sur ce qu'est
TestDescAndFn
, mais pour le moment, la conclusion clé est qu'il existe une caisse appelée
test , qui fait partie du noyau Rust et implémente l'intégralité du runtime pour les tests. L'interface de
test
est instable, donc la seule façon stable d'interagir avec elle est la macro
#[test]
.
Étape 3: génération d'un objet de test
Si vous avez précédemment écrit des tests dans Rust, vous connaissez peut-être certains des attributs facultatifs disponibles pour les fonctions de test. Par exemple, un test peut être annoté avec
#[should_panic]
si nous nous attendons Ă ce que le test provoque une panique. Cela ressemble Ă ceci:
#[test] #[should_panic] fn foo() { panic!("intentional"); }
Cela signifie que nos tests sont plus que de simples fonctions et contiennent des informations de configuration.
test
code ces données de configuration dans une structure appelée
TestDesc . Pour chaque fonction de test dans la caisse,
libsyntax
analysera ses attributs et générera une instance de
TestDesc
. Il combine ensuite
TestDesc
et la fonction de test dans la structure logique
TestDescAndFn
, avec laquelle
test_static_main
fonctionne. Pour ce test, l'instance générée de
TestDescAndFn
ressemble Ă ceci:
self::test::TestDescAndFn { desc: self::test::TestDesc { name: self::test::StaticTestName("foo"), ignore: false, should_panic: self::test::ShouldPanic::Yes, allow_fail: false, }, testfn: self::test::StaticTestFn(|| self::test::assert_test_result(::crate::__test_reexports::foo())), }
Une fois que nous avons construit un tableau de ces objets de test, ils sont transmis au lanceur de test via la liaison générée à l'étape 2. Bien que cette étape puisse être considérée comme faisant partie de la deuxième étape, je veux attirer l'attention sur elle en tant que concept distinct, car ce sera la clé de la mise en œuvre d'un test personnalisé. cadres, mais ce sera un autre article de blog.
Postface: Méthodes de recherche
Bien que j'ai obtenu beaucoup d'informations directement des sources du compilateur, j'ai pu découvrir qu'il existe un moyen très simple de voir ce que fait le compilateur. La compilation nocturne du compilateur a un indicateur instable appelé
unpretty
, que vous pouvez utiliser pour imprimer le code source du module après avoir développé les macros:
$ rustc my_mod.rs -Z unpretty=hir
Note du traducteur
Intéressant pour le plaisir, j'illustrerai le code du cas de test après macro-divulgation:
Code source personnalisé:
#[test] fn my_test() { assert!(2+2 == 4); } fn main() {}
Code après expansion des macros:
#[prelude_import] use std::prelude::v1::*; #[macro_use] extern crate std as std; #[test] pub fn my_test() { if !(2 + 2 == 4) { { ::rt::begin_panic("assertion failed: 2 + 2 == 4", &("test_test.rs", 3u32, 3u32)) } }; } #[allow(dead_code)] fn main() { } pub mod __test_reexports { pub use super::my_test; } pub mod __test { extern crate test; #[main] pub fn main() -> () { test::test_main_static(TESTS) } const TESTS: &'static [self::test::TestDescAndFn] = &[self::test::TestDescAndFn { desc: self::test::TestDesc { name: self::test::StaticTestName("my_test"), ignore: false, should_panic: self::test::ShouldPanic::No, allow_fail: false, }, testfn: self::test::StaticTestFn(::__test_reexports::my_test), }]; }