choroba has asked for the wisdom of the Perl Monks concerning the following question:

Dear monks and nuns,

I need to test a method that is defined in a large code base. I tried to minimise the example, it seems there are two important players: Moose::Exporter and namespace::autoclean.

The 2 following modules are old and have existed in the codebase for years:
lib/Logger.pm

package Logger; use warnings; use strict; use Exporter qw{ import }; our @EXPORT = qw{ log_warn }; sub log_warn { warn @_ } __PACKAGE__
lib/Common.pm
package Common; use warnings; use strict; use experimental qw( signatures ); use Logger; use Moose::Exporter; my ($import, $unimport, $init_meta) = Moose::Exporter->build_import_me +thods( as_is => [\&Logger::log_warn] ); sub import { goto &$import } __PACKAGE__

And here's my new module:
lib/MyObj.pm

package MyObj; use Moose; use Common; use namespace::autoclean; use experimental qw{ signatures }; has value => (is => 'ro', required => 1); sub foo($self) { log_warn('Value too large') if $self->value > 10; } __PACKAGE__->meta->make_immutable

And now I need to test that the object with value 11 logs the warning when foo() is invoked. I started with the following code:
t/01-basic.t

#!/usr/bin/perl use warnings; use strict; use Test::More tests => 2 * 2; use Sub::Override; my @warnings; my $override; use MyObj; BEGIN { $override = 'Sub::Override'->new( 'MyObj::log_warn' => sub { push @warnings, $_[0] }); } for my $test ([1, undef, 'no warnings'], [11, 'Value too large', 'warn +ings']) { my ($value, $warnings, $name) = @$test; @warnings = (); my $o = bless {value => $value}, 'MyObj'; ok($o, 'constructs'); $o->foo; is($warnings[0], $warnings, $name); }

Unfortunately, the tests fail with

t/01-basic.t .. 1..4 Cannot replace non-existent sub (MyObj::log_warn) at t/01-basic.t line + 15. BEGIN failed--compilation aborted at t/01-basic.t line 16. # Looks like your test exited with 255 before it could output anything +. Dubious, test returned 255 (wstat 65280, 0xff00) Failed 4/4 subtests Test Summary Report ------------------- t/01-basic.t (Wstat: 65280 Tests: 0 Failed: 0) Non-zero exit status: 255 Parse errors: Bad plan. You planned 4 tests but ran 0.

Interestingly, removing use namespace::autoclean from MyObj.pm fixes the problem. But that's not what we want to do. I tried overriding the logger subroutine from the other modules, too, and at the end found a combination that worked:
t/01-basic.t

#!/usr/bin/perl use warnings; use strict; use Test::More tests => 2 * 2; use Sub::Override; my @warnings; my $override; use Logger; BEGIN { $override = 'Sub::Override'->new( 'Logger::log_warn' => sub { 'WHATEVER' }); } use MyObj; BEGIN { *MyObj::log_warn = sub { push @warnings, $_[0] }; } for my $test ([1, undef, 'no warnings'], [11, 'Value too large', 'warn +ings']) { my ($value, $warnings, $name) = @$test; @warnings = (); my $o = bless {value => $value}, 'MyObj'; ok($o, 'constructs'); $o->foo; is($warnings[0], $warnings, $name); }

I can use *Logger::log_warn = sub {...} as well, but I can't use Sub::Override for MyObj::low_warn.

Is there a way how to simplify the test so it's less hackish? Mocking just a single subroutine would be the best.

map{substr$_->[0],$_->[1]||0,1}[\*||{},3],[[]],[ref qr-1,-,-1],[{}],[sub{}^*ARGV,3]

Replies are listed 'Best First'.
Re: Mocking with namespace::autoclean and Moose::Exporter
by NERDVANA (Priest) on Nov 15, 2024 at 18:00 UTC
    You're exporting a function from Logger (into the package MyObj), then compiling a sub that captures a reference to that function, then using namespace::autoclean to remove the function from the package. So, it should be entirely expected that you can't override the function later since you removed it from the MyObj package.

    I didn't expect your final solution there to work, but what I think you did was confuse ::autoclean into not removing the function from the package. ::autoclean does heuristic guessing games about where a function came from, so maybe after overriding it with Sub::Override it ends up not getting removed. Personally I prefer namespace::clean since it has a simpler mode of operation.

    I think your simplest option is to override the Logger::log_warn at the top of your unit test, globally, and then just work with that.

    BEGIN { $override= Sub::Override->new( 'Logger::log_warn' => sub { push @wawrnings, $_[0] if caller eq 'MyObj'; } ) }
      Please, try to show more than just a snippet. The exact placement of it might be important. I wasn't able to make it work: without use Logger;, it fails with
      Cannot replace non-existent sub (Logger::log_warn) at t/01-basic.t lin +e 18. BEGIN failed--compilation aborted at t/01-basic.t line 18.

      while with it, it fails a bit later with

      1..4 ok 1 - constructs ok 2 - no warnings ok 3 - constructs Undefined subroutine &MyObj::log_warn called at /home/choroba/_/0/lib/ +MyObj.pm line 11.

      Which is exactly why I asked the original question.

      map{substr$_->[0],$_->[1]||0,1}[\*||{},3],[[]],[ref qr-1,-,-1],[{}],[sub{}^*ARGV,3]
        This is what I had in mind:
        #!/usr/bin/perl use warnings; use strict; use Test::More tests => 2 * 2; use Sub::Override; my @warnings; my $override; use Logger; BEGIN { $override = 'Sub::Override'->new( 'Logger::log_warn' => sub { push @warnings, $_[0] } ); } use MyObj; for my $test ([1, undef, 'no warnings'], [11, 'Value too large', 'warn +ings']) { my ($value, $warnings, $name) = @$test; @warnings = (); my $o = bless {value => $value}, 'MyObj'; ok($o, 'constructs'); $o->foo; is($warnings[0], $warnings, $name); }
        but I hadn't tested it. It does in fact fail, but only because you have a bug that needs fixed in the code being tested :-)

        Edit: Turns out when you replace the Logger sub, Moose::Exporter can't determine the sub name from the coderef. Export it like this and it works:

        my ($import, $unimport, $init_meta) = Moose::Exporter->build_import_me +thods( as_is => ['Logger::log_warn'] ); sub import { goto &$import }