测试

本节中的主题重点介绍了测试区块链逻辑的工具和技术。

单元测试

在为运行时构建逻辑时,你需要例行地测试逻辑是否按预期工作。你可以使用 Rust 提供的单元测试框架为运行时创建单元测试。在创建一个或多个单元测试之后,可以使用 cargo test 命令执行测试。例如,可以通过运行以下命令运行为运行时创建的所有测试:

cargo test

有关使用Rust cargo test 命令和测试框架的更多信息,可运行以下命令:

cargo help test

在一个mock运行时中测试pallet日志

除了可以使用 Rust 测试框架进行单元测试之外,还可以通过构建 mock 运行时环境来验证运行时中的逻辑。配置类型 Test 被定义为 Rust 枚举,其中包含模拟运行时中使用的每个 pallet configuration traits 的实现。

frame_support::construct_runtime!(
 pub enum Test where
  Block = Block,
  NodeBlock = Block,
  UncheckedExtrinsic = UncheckedExtrinsic,
 {
  System: frame_system::{Pallet, Call, Config, Storage, Event<T>},
  TemplateModule: pallet_template::{Pallet, Call, Storage, Event<T>},
 }
);

impl frame_system::Config for Test {
 // -- snip --
 type AccountId = u64;
}

如果 Test 实现了 pallet_balances::Config,则可能使用 u64 作为 Balance 类型。例如:

impl pallet_balances::Config for Test {
 // -- snip --
 type Balance = u64;
}

通过分配 pallet_balances::Balanceframe_system::AccountIdu64,测试帐户和余额仅需要在模拟运行时跟踪一个(AccountId: u64, Balance: u64)映射。

在一个mock运行时中测试存储

sp-io crate 公开了一个 TestExternalities 实现,你可以使用它在一个模拟环境中测试存储。它是内存中的类型别名,它是substrate_state_machine substrate_state_machine 中基于 hashmap 的外部性实现,称为 TestExternalities

下面的示例演示定义一个名为 ExtBuilder 的结构体来构建 TestExternalities 的实例,并将块号设置为 1。

pub struct ExtBuilder;

impl ExtBuilder {
 pub fn build(self) -> sp_io::TestExternalities {
  let mut t = system::GenesisConfig::default().build_storage::<TestRuntime>().unwrap();
  let mut ext = sp_io::TestExternalities::new(t);
  ext.execute_with(|| System::set_block_number(1));
  ext
 }
}

要在单元测试中创建测试环境,调用构建方法来使用默认的创世配置生成 TestExternalities

#[test]
fn fake_test_example() {
 ExtBuilder::default().build_and_execute(|| {
  // ...test logics...
 });
}

Externalities 的自定义实现允许你构建提供对外部节点特性访问的运行时环境。另一个例子可以在 offchain 模块中找到。offchain 模块维护自己的 Externalities 实现。

创世配置

在前面的示例中,ExtBuilder::build() 方法使用默认的起源配置来构建模拟运行时环境。在许多情况下,在测试之前设置存储是很方便的。例如你可能希望在测试之前预设置帐户的余额。

frame_system::Config 的实现中,AccountIdBalance 都被设置为 u64。你可以将 (u64, u64) 一对放在 balances vec中,以 seed (AccountId, Balance) 一对作为帐户 balances。例如:

impl ExtBuilder {
 pub fn build(self) -> sp_io::TestExternalities {
  let mut t = frame_system::GenesisConfig::default().build_storage::<Test>().unwrap();
  pallet_balances::GenesisConfig::<Test> {
   balances: vec![
    (1, 10),
    (2, 20),
    (3, 30),
    (4, 40),
    (5, 50),
    (6, 60)
   ],
  }
   .assimilate_storage(&mut t)
   .unwrap();

  let mut ext = sp_io::TestExternalities::new(t);
  ext.execute_with(|| System::set_block_number(1));
  ext
 }
}

在本例中,账户 1 有余额 10,账户 2 有余额 20,以此类推。

用于定义 pallet 的创世配置的确切结构取决于 pallet 的 GenesisConfig 结构定义。例如在 Balances pallet 中,它被定义为:

pub struct GenesisConfig<T: Config<I>, I: 'static = ()> {
 pub balances: Vec<(T::AccountId, T::Balance)>,
}

区块生产

模拟区块生产以验证预期行为在区块生产中是否存在是有用的。

一种简单的方法是,使用 System::block_number() 作为唯一输入,在来自所有模块的 on_initializeon_finalize 调用之间递增 System 模块的区块号。尽管对运行时代码来说缓存对存储或系统模块的调用是很重要的,但是测试环境脚手架应该优先考虑可读性,以促进未来可继续容易的维护。

fn run_to_block(n: u64) {
 while System::block_number() < n {
  if System::block_number() > 1 {
   ExamplePallet::on_finalize(System::block_number());
   System::on_finalize(System::block_number());
  }
  System::set_block_number(System::block_number() + 1);
  System::on_initialize(System::block_number());
  ExamplePallet::on_initialize(System::block_number());
 }
}

on_finalizeon_initialize 方法只能从 ExamplePallet 调用,如果 pallet trait 实现了 frame_support::traits::{OnInitialize, OnFinalize} traits,分别在每个块之前和之后执行运行时方法中编码的逻辑。

然后按以下方式调用此函数。

#[test]
fn my_runtime_test() {
 with_externalities(&mut new_test_ext(), || {
  assert_ok!(ExamplePallet::start_auction());
  run_to_block(10);
  assert_ok!(ExamplePallet::end_auction());
 });
}

调试

在软件开发的各个阶段,调试都是必要的,区块链也不例外。大多数常见的 Rust 调试工具同样也适用于 Substrate。

日志工具

你可以使用 Rust 的日志 API 调试运行时,它附带了许多宏,包括 debuginfo

例如在更新带有 log crate 的 pallet 的 Cargo.toml 的文件之后,只需使用 log::info! log 到你的 console:

pub fn do_something(origin) -> DispatchResult {

	let who = ensure_signed(origin)?;
	let my_val: u32 = 777;

	Something::put(my_val);

	log::info!("called by {:?}", who);

	Self::deposit_event(RawEvent::SomethingStored(my_val, who));
	Ok(())
}

可打印trait

可打印trait 是一种在 no_std 和在 std 情况下从运行时打印的方法。print 函数适用于实现了 Printable trait 的任何类型。Substrate 默认为某些类型(u8, u32, u64, usize, &[u8], &str)实现此特性。你也可以为你的自定义类型实现它,下面是一个使用 node-template 作为示例代码库为 pallet 的 Error 类型实现它的示例。

use sp_runtime::traits::Printable;
use sp_runtime::print;
#[frame_support::pallet]
pub mod pallet {
	// The pallet's errors
	#[pallet::error]
	pub enum Error<T> {
		/// Value was None
		NoneValue,
		/// Value reached maximum and cannot be incremented further
		StorageOverflow,
	}

	impl<T: Config> Printable for Error<T> {
		fn print(&self) {
			match self {
				Error::NoneValue => "Invalid Value".print(),
				Error::StorageOverflow => "Value Exceeded and Overflowed".print(),
				_ => "Invalid Error Case".print(),
			}
		}
	}
}
/// takes no parameters, attempts to increment storage value, and possibly throws an error
pub fn cause_error(origin) -> dispatch::DispatchResult {
	// Check it was signed and get the signer. See also: ensure_root and ensure_none
	let _who = ensure_signed(origin)?;

	print!("My Test Message");

	match Something::get() {
		None => {
			print(Error::<T>::NoneValue);
			Err(Error::<T>::NoneValue)?
		}
		Some(old) => {
			let new = old.checked_add(1).ok_or(
				{
					print(Error::<T>::StorageOverflow);
					Error::<T>::StorageOverflow
				})?;
			Something::put(new);
			Ok(())
		},
	}
}

运行带有 RUST_LOG 环境变量的节点二进制文件以打印值。

RUST_LOG=runtime=debug ./target/release/node-template --dev

每次调用运行时函数时,这些值都打印在终端或标准输出中。

2020-01-01 tokio-blocking-driver DEBUG runtime  My Test Message  <-- str implements Printable by default
2020-01-01 tokio-blocking-driver DEBUG runtime  Invalid Value    <-- the custom string from NoneValue
2020-01-01 tokio-blocking-driver DEBUG runtime  DispatchError
2020-01-01 tokio-blocking-driver DEBUG runtime  8
2020-01-01 tokio-blocking-driver DEBUG runtime  0                <-- index value from the Error enum definition
2020-01-01 tokio-blocking-driver DEBUG runtime  NoneValue        <-- str which holds the name of the ident of the error

请记住,向运行时添加打印函数会增加 Rust 和 Wasm 二进制文件的大小,在生产环境中不要加入调试代码。

Substrate自身的打印函数

对于传统用例,Substrate 为 Print debugging(或 tracing)提供了额外的工具。你可以使用 print function 记录运行时执行的状态。

use sp_runtime::print;

// --snip--
pub fn do_something(origin) -> DispatchResult {
	print!("Execute do_something");

	let who = ensure_signed(origin)?;
	let my_val: u32 = 777;

	Something::put(my_val);

	print!("After storing my_val");

	Self::deposit_event(RawEvent::SomethingStored(my_val, who));
	Ok(())
}
// --snip--

使用 RUST_LOG 环境变量启动链,以查看打印日志。

RUST_LOG=runtime=debug ./target/release/node-template --dev

如果 Error 被触发,这些值将打印在终端或标准输出中。

2020-01-01 00:00:00 tokio-blocking-driver DEBUG runtime  Execute do_something
2020-01-01 00:00:00 tokio-blocking-driver DEBUG runtime  After storing my_val

If std

传统的 print 函数允许你打印并获得 Printable trait 的实现。然而有一些传统的用例,可能想要做更多的事情,而不仅仅是打印,或者仅仅为了调试目的,而不必考虑 Substrate-specific traits。 if_std! macro 在这种情况下是有用的。

使用此宏的一个警告是,只有当你实际运行 runtime 的 native 版本时,内部的代码才会执行。

use sp_std::if_std; // Import into scope the if_std! macro.

println! 语句应该在 if_std 宏中。

#[pallet::call]
impl<T: Config<I>, I: 'static> Pallet<T, I> {
		// --snip--
		pub fn do_something(origin) -> DispatchResult {

			let who = ensure_signed(origin)?;
			let my_val: u32 = 777;

			Something::put(my_val);

			if_std! {
				// This code is only being compiled and executed when the `std` feature is enabled.
				println!("Hello native world!");
				println!("My value is: {:#?}", my_val);
				println!("The caller account is: {:#?}", who);
			}

			Self::deposit_event(RawEvent::SomethingStored(my_val, who));
			Ok(())
		}
		// --snip--
}

每次调用运行时函数时,这些值都打印在终端或标准输出中。

$		2020-01-01 00:00:00 Substrate Node
		2020-01-01 00:00:00   version x.y.z-x86_64-linux-gnu
		2020-01-01 00:00:00   by Anonymous, 2017, 2020
		2020-01-01 00:00:00 Chain specification: Development
		2020-01-01 00:00:00 Node name: my-node-007
		2020-01-01 00:00:00 Roles: AUTHORITY
		2020-01-01 00:00:00 Imported 999 (0x3d7a…ab6e)
		# --snip--
->		Hello native world!
->		My value is: 777
->		The caller account is: d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d (5GrwvaEF...)
		# --snip--
		2020-01-01 00:00:00 Imported 1000 (0x3d7a…ab6e)

基准测试

Substrate 和 FRAME 为你的区块链开发自定义逻辑提供了一个灵活的框架。这种灵活性使你能够设计复杂的交互式 pallet 并实现复杂的运行时逻辑。然而,确定分配给 pallet 中函数的适当权重可能是一项困难的任务。基准测试使你能够测量在运行时和不同条件下执行不同函数所需的时间。如果你使用基准测试为函数调用分配准确的权重,你可以防止你的区块链过载、无法生成区块或受到恶意参与者的拒绝服务(DoS)攻击。

为什么要对pallet进行基准测试

理解执行不同函数所需的计算资源是很重要的,包括像 on_initializeverify_unsigned 这样的运行时函数,以保证运行时的安全,并允许运行时根据可用的资源需要包含哪些交易还是排除哪些交易。

基于可用资源包含或排除交易的能力确保运行时可以继续生成和导入区块,而不会中断服务。例如如果你有一个需要密集计算的函数调用,执行该调用可能会超过生成或导入块所允许的最大时间,从而中断块处理过程或完全停止区块链进程。基准测试帮助你验证不同函数所需的执行时间是否在合理的范围内。

同样恶意用户可能会试图通过反复执行需要密集计算或无法准确反映其所需计算的函数调用来破坏网络服务。如果执行函数调用的成本无法准确反映所消耗的计算资源,则无法阻止恶意用户攻击网络。由于基准测试可帮助你评估与执行交易相关的权重,因此它还可以帮助你确定适当的交易费用。根据你的基准测试,你可以设置代表通过对区块链执行指定调用所消耗的资源的费用。

开发一个线性模型

在高水平上,基准测试需要执行以下步骤:

  • 编写自定义基准测试逻辑,为一个函数执行指定代码路径。
  • 在 WebAssembly 执行环境中使用指定的硬件集合和指定的运行时配置执行基准测试逻辑。
  • 在可能影响函数所需执行时间的受控范围内执行基准逻辑。
  • 对函数中的每个组件执行多次基准测试,以隔离和删除异常值。

根据通过执行基准逻辑生成的结果,基准测试工具创建了一个函数的线性模型,该模型跨越所有组件。函数的线性模型使你能够估计执行指定代码路径所需的时间,并做出明智的决策,而无需在运行时实际花费大量资源。基准测试假设所有交易都具有线性复杂性,因为较高复杂性的函数被认为对运行时是危险的,因为随着运行时状态或输入变得过于复杂,这些函数的权重可能会爆炸。

基准测试和权重

交易、权重和费用中所述,基于 Substrate 的链使用权重概念表示在区块中执行交易所需的时间。在交易中执行任何特定调用所需的时间取决于几个因素,包括以下几项:

  • 计算复杂性。
  • 存储复杂性。
  • 数据库读写操作需要的。
  • 使用的硬件。

要计算交易的适当权重,可以使用基准参数来测量在不同硬件上执行函数调用所需的时间,使用不同的变量值,并重复多次。然后你可以使用基准测试的结果来建立近似的最坏情况权重,以表示执行每个函数调用和每个代码路径所需的资源。费用基于最坏情况权重。如果实际调用的性能优于最坏情况,则调整权重并返回任何超额费用。

因为权重是基于指定物理机器的计算时间的通用度量单位,所以任何函数的权重都可以基于用于基准测试的指定硬件而改变。

通过建模每个运行时函数的预期权重,区块链能够计算在特定时间段内可以执行多少交易或系统级调用。

在 FRAME 内,可以调度的每个函数调用必须具有 #[weight] 注释,该注释可以返回给定输入的该函数最坏情况下执行的预期权重。基准测试框架会自动为你生成一个包含这些公式的文件。

基准测试工具

基准测试框架提供的工具可帮助你在运行时添加、测试、运行和分析函数的 benchmarks。帮助你确定执行函数调用所需时间的基准测试工具包括:

编译节点时,默认情况下禁用端到端基准测试管道。如果要运行基准测试,则需要编译一个带有 runtime-benchmarks Rust 特性标志的节点。

编写基准测试

编写运行时基准测试类似于为 pallet 编写单元测试。与单元测试一样,基准测试必须在代码中执行特定的逻辑路径。在单元测试中,你将检查代码的成功和失败结果。对于基准测试,你希望执行 most computationally intensive 的路径。

在编写基准测试时,你应该考虑可能影响函数复杂性的特定条件,如存储或运行时状态。例如如果在 For 循环中触发更多迭代会增加数据库读写操作的数量,则应设置触发此条件的基准测试,以更准确地表示函数将如何执行。

如果一个函数根据用户输入或其他条件执行不同的代码路径,你可能不知道哪个路径是计算最密集的路径。为了帮助你了解代码中的复杂性可能变得难以管理的地方,你应该为每个可能的执行路径创建一个基准测试。基准测试可以帮助你确定代码中可能需要强制识别的边界,例如通过限制向量中的元素数量或限制 for 循环中的迭代次数来控制用户如何与 pallet 交互。

你可以在所有预构建的 FRAME pallets 中找到端到端基准的的示例。

测试基准测试

你可以使用为单元测试 pallet 创建类似的模拟运行时执行基准测试。使用的基准测试宏在你的 benchmarking.rs 模块中可以自动的生成测试函数。例如:

fn test_benchmark_[benchmark_name]<T>::() -> Result<(), &'static str>

你可以将基准测试函数添加到单元测试中,并确保函数的结果是 Ok(())

验证块

通常你只需要检查基准测试是否返回 Ok(()),因为该结果表明函数已成功执行。但是如果你想要验证任何最终条件,比如运行时的最终状态,你可以选择在基准测试中包含一个 verify 块。额外的 verify 块不会影响你的最终基准测试过程的结果。

使用benchmarks运行单元测试

要运行基准测试,你需要指定要测试的包并启用 runtime-benchmarks 特性。例如你可以通过运行以下命令来测试 Balances pallet:

cargo test --package pallet-balances --features runtime-benchmarks

添加基准测试

每个 pallet 中包含的基准测试不会自动添加到节点中。要执行这些基准测试,你需要实现框架 frame_benchmarking::Benchmark trait。你可以在 Substrate 节点中看到如何操作的示例。

假设你的节点上已经设置了一些基准测试,你只需要将 pallet 添加到 define_benchmarks! 宏:

#[cfg(feature = "runtime-benchmarks")]
mod benches {
	define_benchmarks!(
		[frame_benchmarking, BaselineBench::<Runtime>]
		[pallet_assets, Assets]
		[pallet_babe, Babe]
    ...
    [pallet_mycustom, MyCustom]
    ...

在添加了 pallet 之后,使用 runtime-benchmarks 特性标志编译节点二进制文件。例如:

cd bin/node/cli
cargo build --profile=production --features runtime-benchmarks

production 配置文件应用了各种编译器优化。这些优化大大降低了编译过程的速度。如果你只是在测试,不需要最终的指标,请使用 --release 命令行选项而不是 production 配置文件。

运行基准测试

在编译了启用基准测试的节点二进制代码之后,你需要执行基准测试。如果使用 production 配置文件编译节点,则可以通过运行以下命令列出可用的基准测试:

./target/production/node-template benchmark pallet --list

对所有pallets中的所有函数进行基准测试

要执行运行时的所有基准测试,可以运行类似于以下的命令:

./target/production/node-template benchmark pallet \
    --chain dev \
    --execution=wasm \
    --wasm-execution=compiled \
    --pallet "*" \
    --extrinsic "*" \
    --steps 50 \
    --repeat 20 \
    --output pallets/all-weight.rs

在本例中该命令创建一个输出文件,名为 all-weight.rs 为你的运行时实现了 WeightInfo trait。

在pallet中对特定的函数进行基准测试

要在特定 pallet 中为特定函数执行基准测试,可以运行类似于下面的命令:

./target/production/node-template benchmark pallet \
    --chain dev \
    --execution=wasm \
    --wasm-execution=compiled \
    --pallet pallet_balances \
    --extrinsic transfer \
    --steps 50 \
    --repeat 20 \
    --output pallets/transfer-weight.rs

该命令为选定的 pallet 创建一个输出文件,例如 transfer-weight.rspallet_balances pallet 实现了 WeightInfo trait。

使用一个格式化基准测试的模板

基准测试命令行接口使用 Handlebars 模板来格式化最终输出文件。你可以选择传递 --template 命令行选项来指定自定义模板而不是默认模板。在模板中,你可以访问基准测试命令行接口中 TemplateData 结构提供的所有数据。

输出生成中包含了一些自定义 Handlebars 帮助程序:

  • underscore:将下划线添加到字符串右侧的第三个字符。主要用于界定大数。
  • join:为模板加入字符串数组,以空格分隔的字符串。主要用于连接传递给 CLI 的所有参数。

要获取 benchmark 子命令的完整列表,请运行:

./target/production/node-template benchmark --help

要获取 benchmark pallet 子命令可用选项的完整列表,请运行:

./target/production/node-template benchmark pallet --help