Perl 编程练习:测试数据库

测试使用数据库的代码可能有些棘手。最常用的解决方案是设置一个带有测试数据的测试数据库,并针对这个数据库运行测试。当然,这需要维护代码来确保测试数据库处于正确的状态,以便所有测试都可以运行而不会相互影响。这可以从为每个测试删除和重新创建测试数据库,到更细粒度的行级别添加和删除。无论如何,你都在测试中引入了非测试代码,这可能会引入污染的可能性。最终,因为你可以控制测试运行的环境,所以尽管有时会头疼,但你可以管理这个问题。

真正的乐趣只有在决定将你的杰作发布给全世界时才开始。正如任何 CPAN 作者都会告诉你的,一旦发布,你就无法控制其他人运行你的代码的环境。在如此敌对的环境中测试数据库代码可能会让模块开发者和模块安装者感到沮丧。一种常见的方法是允许用户指定特定的数据库连接信息,作为环境变量或命令行参数,如果这些变量不存在,则跳过测试。另一种方法是使用轻量级且非常便携的 SQLite 作为测试数据库(当然,首先要测试用户是否已安装 SQLite)。虽然这些解决方案确实有效,但它们往往很脆弱,最终会增加你作为模块作者可能面临的安装问题的数量。

模块作者该怎么办呢?

DBD::Mock 测试练习

这个编程练习介绍了测试数据库代码的另一种方法,即使用模拟对象,特别是使用 DBD::Mock 模拟 DBI 驱动。在展示任何代码之前,我想解释一下模拟对象的基本哲学以及 DBD::Mock 的位置。

什么是模拟对象?

在编写单元测试时,最好尽可能地将要测试的内容隔离。你想要确保不仅只测试所涉及的代码,而且测试之外代码中的错误或问题不会在测试中引入假阴性。不幸的是,这种完全解耦的设计理想只是一个理想。在现实世界中,代码有依赖关系,你不能为了测试而移除这些依赖。这就是模拟对象发挥作用的地方。

模拟对象正如其名;它们是“模拟”或“虚假”的对象。良好的多态思想认为,你应该能够用一个实现相同接口的对象替换另一个对象。模拟对象通过允许你在测试期间用可能的最小模拟实现替换真实对象来利用这一点。这允许你集中精力测试代码,而不必担心诸如数据库是否仍在运行或是否有数据库可供测试等愚蠢的事情。

DBD::Mock 的位置在哪里?

DBD::Mock 是一个模拟 DBI 驱动程序,它允许您测试使用 DBI 的代码,而无需担心数据库的谁、什么、何时和何地。DBD::Mock 还有助于减少数据库账本代码的数量,因为它完全消除了数据库,而是详细记录了通过 DBI 执行的所有操作。当然,数据库交互/通信是双向的,所以 DBD::Mock 也允许您用模拟的记录集初始化驱动程序。DBD::Mock 使得伪造大多数(非供应商特定)的数据库交互成为可能,以便编写测试。对于更详细的文档,建议阅读 DBD::Mock POD 文档本身。

示例 DBI 代码

在过去的 Perl 代码 kata 传统中,这里有一些简化的代码,您可以针对这些代码编写测试。这段代码应该足够简单以理解,但也足够复杂以展示 DBD::Mock 的实际用途。

package MyApp::Login;

use DBI;

my $MAX_LOGIN_FAILURES = 3;

sub login {
  my ($dbh, $u, $p) = @_;
  # look for the right username and password
  my ($user_id) = $dbh->selectrow_array(
      "SELECT user_id FROM users WHERE username = '$u' AND password = '$p'"
  );
  # if we find one, then ...
  if ($user_id) {
      # log the event and return success
      $dbh->do(
          "INSERT INTO event_log (event) VALUES('User $user_id logged in')"
      );
      return 'LOGIN SUCCESSFUL';
  }
  # if we don't find one then ...
  else {
      # see if the username exists ...
      my ($user_id, $login_failures) = $dbh->selectrow_array(
          "SELECT user_id, login_failures FROM users WHERE username = '$u'"
      );
      # if we do have a username, and the password doesnt match then
      if ($user_id) {
          # if we have not reached the max allowable login failures then
          if ($login_failures < $MAX_LOGIN_FAILURES) {
              # update the login failures
              $dbh->do(qq{
                  UPDATE users
                  SET login_failures = (login_failures + 1)
                  WHERE user_id = $user_id
              });
              return 'BAD PASSWORD';
          }
          # otherwise ...
          else {
              # we must update the login failures, and lock the account
              $dbh->do(
                  "UPDATE users SET login_failures = (login_failures + 1), " .
                  "locked = 1 WHERE user_id = $user_id"
              );
              return 'USER ACCOUNT LOCKED';
          }
      }
      else {
          return 'USERNAME NOT FOUND';
      }
  }
}

通过这段代码有四种不同的路径,每种路径都会产生四种返回消息之一:登录成功密码错误用户账户被锁定用户名未找到。看看您是否可以编写足够的测试来覆盖这四种路径。您可以使用 Devel::Cover 验证这一点。

利用您对 DBD::Mock 的了解,去编写测试吧!下一页将更详细地介绍 DBD::Mock 并提供编写适当测试的策略。在继续之前,您应该花费 30 到 45 分钟的时间编写测试。

技巧、窍门和建议

因为 DBD::Mock 是 DBD 驱动的实现,所以它的使用与 DBI 非常相似。DBD::Mock 在模拟数据库交互方面是独一无二的。以下是对这些 DBD::Mock 功能的简要介绍。

幸运的是,连接到数据库是您常规 DBI 代码中唯一需要特定于 DBD::Mock 的部分,因为 DBI 根据给定的 dsn 字符串选择驱动程序。要使用 DBD::Mock 做到这一点

my $dbh = DBI->connect('dbi:Mock:', '', '');

因为 DBI 实际上不会连接到真实的数据库,所以您不需要数据库名称、用户名或密码。接下来要做的事情是用结果集初始化数据库驱动程序。通过 $dbh 处理器的 mock_add_resultset 属性来完成此操作。

$dbh->{mock_add_resultset} = [
  [ 'user_id', 'username', 'password' ],
  [ 1, 'stvn', '****' ]
];

DBD::Mock 将在下一次执行此 $dbh 上的语句时返回此特定结果集。请注意,第一行是列名,而所有后续行都是数据。当然,在某些情况下,这可能不够具体,因此 DBD::Mock 还允许将特定的 SQL 语句绑定到特定的结果集

$dbh->{mock_add_resultset} = {
  sql     => "SELECT * FROM user_table WHERE username = 'stvn'",
  results => [[ 'user_id', 'username', 'password' ],
              [ 1, 'stvn', '****' ]]
};

现在,每当执行 SELECT * FROM user_table WHERE username = 'stvn' 语句时,DBD::Mock 将返回此结果集。DBD::Mock 还可以通过 mock_add_resultset 指定 UPDATEINSERTDELETE 语句影响的行数。例如,在这里,DBI 将 DELETE 语句视为已删除 3 行数据

$dbh->{mock_add_resultset} = {
  sql     => "DELETE FROM session_table WHERE active = 0",
  results => [[ 'rows' ], [], [], []]
};

DBD::Mock 0.18 版本引入了 DBD::Mock::Session 对象,它允许编写数据库交互的 会话 脚本 - DBD::Mock 可以验证会话是否正确执行。以下是一个 DBD::Mock::Session 的示例

$dbh->{mock_session} = DBD::Mock::Session->new('session_reaping' => (
  {
  statement => "UPDATE session_table SET active = 0 WHERE timeout < NOW()",
  results  => [[ 'rows' ], [], [], []]
  },
  {
  statement => "DELETE FROM session_table WHERE active = 0",
  results  => [[ 'rows' ], [], [], []]
  }
));

会话中每个语句块的散列引用应与使用 mock_add_resultset 添加的值非常相似,唯一的区别是将 statement 替换为 sql。DBD::Mock 将确保第一个运行的语句与会话中的第一个语句匹配,如果不匹配,将引发错误(以 PrintErrorRaiseError 指定的方式)。然后 DBD::Mock 将继续通过会话,直到达到最后一个语句,验证每个运行的语句是否按照指定的顺序匹配。您还可以在 DBD::Mock::Session 的 statement 插槽中使用正则表达式引用和代码引用进行更复杂的比较。有关这些功能的更多详细信息,请参阅文档。

在用结果集填充 $dbh 后,下一步是运行将使用这些结果集的 DBI 代码。这只是正常的日常 DBI 代码,没有 DBD::Mock 的独特之处。

所有 DBI 代码运行完毕后,可以遍历所有已执行的语句,并使用位于 $dbhmock_all_history 属性中的 DBD::Mock::StatementTrack 对象数组来检查它们。以下是一个简单示例,用于打印每个语句运行的信息以及使用的绑定参数。

my $history = $dbh->{mock_all_history};
foreach my $s (@{$history}) {
  print "Statement  : " . $s->statement() . "\n" .
        "bind params: " . (join ', ', @{$s->bound_params()}) . "\n";
}

DBD::Mock::StatementTrack 还提供了许多其他语句信息。请参阅 DBD::Mock POD 文档以获取更多详细信息。

现在,来说说测试。

解决方案

Perl 有句俗语,“条条大路通罗马”,DBD::Mock 也是如此。测试代码有四个不同的代码路径,测试解决方案将使用每一种来演示使用 DBD::Mock 编写测试的不同技术。

第一个示例是 登录成功 路径。代码使用 mock_add_resultset 的数组版本来填充 $dbh,然后检查 mock_all_history 以确保所有语句都按正确的顺序运行。

use Test::More tests => 4;

use MyApp::Login;

my $dbh = DBI->connect('dbi:Mock:', '', '');

$dbh->{mock_add_resultset} = [[ 'user_id' ], [ 1 ]];
$dbh->{mock_add_resultset} = [[ 'rows' ], []];

is(MyApp::Login::login($dbh, 'user', '****'),
   'LOGIN SUCCESSFUL',
   '... logged in successfully');

my $history = $dbh->{mock_all_history};

cmp_ok(@{$history}, '==', 2, '... we ran 2 statements');

is($history->[0]->statement(),
   "SELECT user_id FROM users WHERE username = 'user' AND password =
    '****'", '... the first statement is correct');

is($history->[1]->statement(),
   "INSERT INTO event_log (event) VALUES('User 1 logged in')",
   '... the second statement is correct');

这是 DBD::Mock 最简单和最直接的使用方式。只需用适当数量的结果集填充 $dbh,运行代码,然后测试以验证是否正确调用了正确的 SQL 并按正确的顺序调用。这几乎再简单不过了。然而,这种方法也有其缺点,最明显的缺点是没有任何方法可以将 SQL 直接关联到结果集(就像在实际数据库中发生的那样)。然而,DBD::Mock 以添加的顺序返回结果集,因此存在隐含的事件顺序,可以在稍后通过 mock_all_history 验证。

下一个示例是 用户名未找到 路径。测试代码使用 mock_add_resultset 的哈希版本来填充 $dbh,并在之后使用 mock_all_history_iterator 检查语句。

use Test::More tests => 4;

use MyApp::Login;

my $dbh = DBI->connect('dbi:Mock:', '', '');

$dbh->{mock_add_resultset} = {
  sql => "SELECT user_id FROM users WHERE username = 'user'
       AND password = '****'", results => [[ 'user_id' ],
       [ undef ]]
};
$dbh->{mock_add_resultset} = {
  sql => "SELECT user_id, login_failures FROM users WHERE
       username = 'user'", results => [[ 'user_id',
       'login_failures' ], [ undef, undef ]]
};

is(MyApp::Login::login($dbh, 'user', '****'),
  'USERNAME NOT FOUND',
  '... username is not found');

my $history_iterator = $dbh->{mock_all_history_iterator};

is($history_iterator->next()->statement(),
   "SELECT user_id FROM users WHERE username = 'user' AND password = '****'",
   '... the first statement is correct');

is($history_iterator->next()->statement(),
   "SELECT user_id, login_failures FROM users WHERE username = 'user'",
   '... the second statement is correct');

ok(!defined($history_iterator->next()), '... we have no more statements');

这种方法允许将特定的 SQL 语句与特定的结果集关联起来。然而,它失去了语句的隐含顺序,这是 mock_add_resultset 数组版本的优点之一。您可以手动检查此顺序,使用 mock_all_history_iterator(它简单地遍历由 mock_all_history 返回的数组)。使用 mock_all_history_iterator 的一个优点是,如果需要添加、删除或重新排序您的 SQL 语句,则不需要更改测试中的所有 $history 数组索引。还有一个好主意是检查是否只运行了两个预期的语句;通过利用迭代器在其内容耗尽时返回未定义值的事实来完成此操作。

下一个示例是 用户账户被锁定 路径。测试代码使用 DBD::Mock::Session 对象来测试此路径。建议将 $dbh 设置为 RaiseError,这样当 DBD::Mock::Session 遇到问题时,它会抛出异常。

use Test::More tests => 2;
use Test::Exception;

use MyApp::Login;

my $dbh = DBI->connect('dbi:Mock:', '', '', { RaiseError => 1, PrintError => 0 });

my $lock_user_account = DBD::Mock::Session->new('lock_user_account' => (
  {
      statement => "SELECT user_id FROM users WHERE username = 'user' AND
           password = '****'", results   => [[ 'user_id' ], [ undef]]
  },
  {
      statement => "SELECT user_id, login_failures FROM users WHERE
           username = 'user'", results   => [[ 'user_id', 'login_failures' ],
           [ 1, 4 ]]
  },
  {
      statement => "UPDATE users SET login_failures = (login_failures + 1),
      locked = 1 WHERE user_id = 1", results   => [[ 'rows' ], []]
  }
));

$dbh->{mock_session} = $lock_user_account;
my $result;
lives_ok {
    $result = MyApp::Login::login($dbh, 'user', '****')
} '... our session ran smoothly';
is($result,
  'USER ACCOUNT LOCKED',
  '... username is found, but the password is wrong,
       so we lock the the user account');

DBD::Mock::Session 方法有几个优点。首先,SQL 语句与特定的结果集相关联(就像 mock_add_resultset 的哈希版本一样)。其次,存在语句的显式顺序(就像 mock_add_resultset 的数组版本一样)。DBD::Mock::Session 将验证会话是否被正确遵循,如果不正确,则会抛出错误。这个示例的一个缺点是使用静态字符串来比较 SQL。然而,DBD::Mock::Session 可以使用其他方法,如下一个和最后一个示例所示。

下一个也是最后一个示例是 密码错误 路径。测试代码展示了 DBD::Mock::Session 对象的一些更复杂的可能性。

use Test::More tests => 2;
use Test::Exception;

use SQL::Parser;
use Data::Dumper;

use MyApp::Login;

my $dbh = DBI->connect('dbi:Mock:', '', '', { RaiseError => 1, PrintError => 0 });

my $bad_password = DBD::Mock::Session->new('bad_password' => (
{
  statement => qr/SELECT user_id FROM users WHERE username = \'.*?\' AND
       password = \'.*?\'/, results   => [[ 'user_id' ], [ undef]]
},
{
  statement => qr/SELECT user_id, login_failures FROM users WHERE username =
  \'.*?\'/, results   => [[ 'user_id', 'login_failures' ], [ 1, 0 ]]
},
{
  statement => sub {
      my $parser1 = SQL::Parser->new('ANSI');
      $parser1->parse(shift(@_));
      my $parsed_statement1 = $parser1->structure();
      delete $parsed_statement1->{original_string};

      my $parser2 = SQL::Parser->new('ANSI');
      $parser2->parse("UPDATE users SET login_failures =
           (login_failures + 1) WHERE user_id = 1");
      my $parsed_statement2 = $parser2->structure();
      delete $parsed_statement2->{original_string};

      return Dumper($parsed_statement2) eq Dumper($parsed_statement1);
  },
  results   => [[ 'rows' ], []]
}
));

$dbh->{mock_session} = $bad_password;

my $result;
lives_ok {
    $result = MyApp::Login::login($dbh, 'user', '****')
} '... our session ran smoothly';
is($result, 'BAD PASSWORD', '... username is found, but the password is wrong');

这种方法使用DBD::Mock::Session的更灵活的SQL比较方法。第一个和第二个语句使用正则表达式进行比较,从而减少了将测试数据硬编码到语句中的需求。第三个语句使用子程序引用来执行SQL比较。正如您在提供的测试代码中可能注意到的,用于BAD PASSWORD路径的UPDATE语句使用了Perl的qq()引用机制来以更自由的形式格式化SQL。这可能会在尝试使用字符串或正则表达式验证SQL时产生复杂性。这里的测试使用SQL::Parser来确定测试语句与代码中运行的语句的功能等价性

结论

我希望这个案例研究已经说明了单元测试DBI代码并不像它可能看起来那么困难或危险。通过使用Mock对象(一般和特定地使用DBD::Mock DBI驱动程序),可以在不接触真实数据库的情况下实现DBI相关代码的100%代码覆盖率。以下是上述测试的Devel::Cover输出

 ---------------------------- ------ ------ ------ ------ ------ ------ ------
 File                           stmt branch   cond    sub    pod   time  total
 ---------------------------- ------ ------ ------ ------ ------ ------ ------
 lib/MyApp/Login.pm            100.0  100.0    n/a  100.0    n/a  100.0  100.0
 Total                         100.0  100.0    n/a  100.0    n/a  100.0  100.0
 ---------------------------- ------ ------ ------ ------ ------ ------ ------

参见 –

标签

反馈

这篇文章有什么问题吗?请通过在GitHub上打开问题或拉取请求来帮助我们