perlunit-testingoopmockingmoo

perl Test::MockModule with DB acces


I'm starting in the unit testing world in Perl. Here is my question: I have a class in Perl (I'm using Moo BTW) and this class has 3 attributes (I'm going to put the code bellow). One of these attributes is an array, and it is generated automatically in the constructor. To generate the array, I need to access to a DB and execute one query.

package Customer;
use 5.010;
use Data::Dumper;
use Moo;
use TT::SQL;

has id => (
  is=>'ro',
  required=>1,
);

has type => (
  is=>'ro',
);

has emails => (
  is=>'rw',
  default => sub {
     my ($self) = @_;
     return $self->getEmails();
  },
  lazy=> 1,
);

sub getEmails
{               
                my $self=shift;
                #obtaining the DB handler
                my $db2 = $self->get_db_handler();
                my $fmuser=$self->id;
                my $type=$self->type;
                my $query;
                #checking the customer type to perform the query
                if ($type eq "tshop")
                {
                $query="SELECT email from XXXXXXX WHERE XXXXX=? and XXXXXXXX=1 and XXXXXXX =1'";
                }
                else
                {
                $query="SELECT email from XXXXXXXX WHERE XXXXX=? and XXXXXXXX=1 and XXXXXXX =1";
                }
                my $ref = $db2->execute($query,$fmuser);
                my @emails;
                #retrieving emails
                while ( my $row = $ref->fetchrow_hashref  ) {
                       @emails=(@emails,"$row->{email}\n");
                  }
                return \@emails;
}

sub get_db_handler
{
        my $user = "XXXXXXX";
    my $password = 'XXXXXXX';
    my $host = "XXXXX";
    my $driver = "Pg";
    my $timezone = "America/New_York";

    my $dsn = "DBI:$driver:database=fm;host=$host";
    my $db = DBI->connect($dsn,$user,$password) or die("Cannot connect to the DB !!");
    return $db;

}

1;

Now I want to run unit tests to check the behavior of the previous class. Until now, i'm using Test::MockModule as next:

use diagnostics; # this gives you more debugging information
use warnings;    # this warns you of bad practices
use strict;      # this prevents silly errors
use Moo;
use Data::Dumper;
use Test::More tests => 2; # for the is() and isnt() functions
use Customer;
use FindBin qw/$RealBin/;
use Test::Deep;
use Test::MockModule;
use DBI;
my $dbh = DBI->connect("dbi:SQLite:dbname=$RealBin/test-cutomer.db","","") or die $DBI::errstr;
$dbh->do("
CREATE TABLE IF NOT EXISTS table (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    field1 INTEGER,
    field2 INTEGER,
    field3 TEXT,
    field4 INTEGER,
    field5 INTEGER
)
");

$dbh->do('
        INSERT INTO table (field1,field2,field3,field4,field5) VALUES 
        (?,?,?,?,?)',undef,undef,92,'XXXXXXXX@XXXXXXXXX',1,1
);

END {
    unlink "$RealBin/test-customer.db";
}

{

my $mock = Test::MockModule->new("Customer");
$mock->mock("get_db_handler", sub { return $dbh });

my $customer=Customer->new(id=>92,type=>'other');

ok(defined $customer);
my $e=$customer->emails;
my @emails=@$e;
my $length=@emails;
is($length,1, 'the emails are OK');
}

I want to mock the get_db_handler method to retrieve the test-customer.db handler and to run the queries over this local DB. So far, I'm getting the following error:

1..2
ok 1
Can't locate object method "execute" via package "DBI::db" at Customer.pm line
    46 (#1)
    (F) You called a method correctly, and it correctly indicated a package
    functioning as a class, but that package doesn't define that particular
    method, nor does any of its base classes.  See perlobj.

Uncaught exception from user code:
    Can't locate object method "execute" via package "DBI::db" at Customer.pm line 46.
 at Customer.pm line 46
    Customer::getEmails('Customer=HASH(0x11359c8)') called at Customer.pm line 23
    Customer::__ANON__('Customer=HASH(0x11359c8)') called at (eval 23) line 18
    Customer::emails('Customer=HASH(0x11359c8)') called at t/stc.t line 66
# Looks like you planned 2 tests but ran 1.
# Looks like your test exited with 2 just after 1.

The script is running OK, I mean, there is no problem with the code. The problem is with the test. Could you please take a look at this? I'll appreciate it a lot. Thanks in advance.


Solution

  • The reason you are getting that error is that your production code is calling execute on the database handle, and not on a statement handle. You have to prepare the query before you can execute it.

    my $sth = $db2->prepare($query);
    my $ref = $sth->execute($fmuser);
    

    Using conventional names like $dbh, $sth and $res for your DBI variables would have helped to spot that easier.


    Test::MockModule is not the right tool for what you are doing. It's useful if you want to mock dependencies in other modules, or possibly only parts of those.

    But right now you have an internal dependency. What you want to do is dependency injection, but your code is not prepared for that, so you need to find a different way.

    I would recommend using Sub::Override for this job. It's fairly simple. It overrides a sub in a lexical scope. That's all you really need here.

    use Sub::Override;
    
    my $sub = Sub::Override->new('frobnicate' => sub { return 'foo' });
    

    So if you use that, your code would look something like the following. Note that I cleaned up some lines into one for the second test-case.

    use strict;
    use warnings;
    
    use Test::More;
    use Sub::Override;    # this line is new
    
    use DBI;
    use FindBin qw/$RealBin/;
    
    #                                                          typo here
    my $dbh = DBI->connect( "dbi:SQLite:dbname=$RealBin/test-customer.db", "", "" )
        or die $DBI::errstr;
    $dbh->do(<<'EOSQL');
    CREATE TABLE IF NOT EXISTS table (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        field1 INTEGER,
        field2 INTEGER,
        field3 TEXT,
        field4 INTEGER,
        field5 INTEGER
    )
    EOSQL
    
    $dbh->do(<<'EOSQL');
            INSERT INTO table (field1,field2,field3,field4,field5) VALUES
            (?,?,?,?,?), undef, undef, 92, 'XXXXXXXX@XXXXXXXXX', 1, 1 );
    EOSQL
    
    {
        # this makes $customer->get_db_handler temporarily return 
        # our $dbh from outside
        my $sub = Sub::Override->new( 'Customer::get_db_handler' => sub {
            return $dbh 
        });
    
        my $customer = Customer->new( id => 92, type => 'other' );
    
        # this test-case is pretty useless        
        ok defined $customer, 'Customer is defined';    
    
        is scalar @{ $customer->emails }, 1, 
            '... and the emails were fetched form the DB';
    }
    
    END {
        unlink "$RealBin/test-customer.db";
    }