diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml index 04ce974..22b20d2 100644 --- a/.github/workflows/testsuite.yml +++ b/.github/workflows/testsuite.yml @@ -94,3 +94,60 @@ jobs: - run: perl Build.PL - run: ./Build - run: ./Build test + + # Exercises every optional-OO-dependency permutation -- the full + # {Moo, Moose, Mouse} cross-product (8 profiles) -- so each OO system is + # tested in isolation and in combination. Uses a clean setup-perl Perl + # (NOT the perl-tester container, which preloads Moose and would defeat the + # `none`/`moo`/`mouse` profiles) and installs each profile's modules + # explicitly (no --with-recommends). The `moo` profile -- Moo present, Moose + # absent -- is the configuration that reproduces GH #93. Support deps are + # paired with the systems whose tests need them: Class::Load + Test::Exception + # for the Moose tests (t/moose_around.t needs Class::Load), Test::Exception + # for t/mouse_basic.t. Tests for an absent system skip_all, so a profile only + # installs what it actually exercises. + oo-deps: + name: oo-deps (${{ matrix.profile.name }}) + env: + PERL_USE_UNSAFE_INC: 0 + AUTOMATED_TESTING: 1 + + runs-on: ubuntu-latest + + needs: [ubuntu] + + strategy: + fail-fast: false + matrix: + profile: + - name: none + install: "" + - name: moo + install: "Moo" + - name: moose + install: "Moose Class::Load Test::Exception" + - name: mouse + install: "Mouse Test::Exception" + - name: moo+moose + install: "Moo Moose Class::Load Test::Exception" + - name: moo+mouse + install: "Moo Mouse Test::Exception" + - name: moose+mouse + install: "Moose Mouse Class::Load Test::Exception" + - name: all + install: "Moo Moose Mouse Class::Load Test::Exception" + + steps: + - uses: actions/checkout@v6 + - uses: shogo82148/actions-setup-perl@v1 + - run: perl -V + # Required deps only -- NO --with-recommends, so the optional OO modules + # are controlled per-profile below. + - name: install required dependencies + run: cpanm --installdeps --notest . + - name: install OO profile dependencies + if: matrix.profile.install != '' + run: cpanm --notest ${{ matrix.profile.install }} + - run: perl Build.PL + - run: ./Build + - run: ./Build test diff --git a/.gitignore b/.gitignore index 4b00e47..d9468eb 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ pm_to_blib Test-MockModule-* docs/superpowers +.tickets/ diff --git a/Build.PL b/Build.PL index 0e7ee28..df322e0 100644 --- a/Build.PL +++ b/Build.PL @@ -47,8 +47,9 @@ my $builder = $class->new( 'Test::Warnings' => 0, }, # Moose, Mouse, Class::Load and Test::Exception are exercised ONLY by the - # t/moose_*.t and t/mouse_*.t MOP-integration tests added for GH #55. Each - # of those files opens with `eval { require Moose } or plan skip_all`, so + # t/moose_*.t and t/mouse_*.t MOP-integration tests added for GH #55, and + # Moo only by the t/moo_*.t tests added for GH #93. Each of those files + # opens with an `eval { require } or plan skip_all`, so # the suite passes cleanly without them. They are therefore OPTIONAL, not # required: declaring them as `recommends` keeps a plain # `cpanm Test::MockModule` from dragging in two full Perl OO systems (GH #90). @@ -65,6 +66,7 @@ my $builder = $class->new( 'Class::Load' => 0, 'Moose' => 0, 'Mouse' => 0, + 'Moo' => 0, }, add_to_cleanup => [ diff --git a/Changes b/Changes index 944e4c9..2ab1099 100644 --- a/Changes +++ b/Changes @@ -1,3 +1,8 @@ +## Unreleased + +## What's Changed +* fix(GH #93): don't require Moose when mocking a Moo class — `_meta_for` no longer triggers Moo's lazy Moose metaclass inflation + ## 0.185.2 — 2026-05-29 ## What's Changed diff --git a/lib/Test/MockModule.pm b/lib/Test/MockModule.pm index c483069..65f99bd 100644 --- a/lib/Test/MockModule.pm +++ b/lib/Test/MockModule.pm @@ -558,6 +558,11 @@ sub _meta_for { return unless $package->can('meta'); my $meta = eval { $package->meta }; return unless ref $meta; + # Moo's ->meta returns a Moo::HandleMoose::FakeMetaClass whose ->isa lazily + # inflates a real Moose metaclass -- which throws when Moose isn't installed. + # Match it by exact ref (not ->isa) BEFORE the isa checks so inflation never + # fires; Moo classes fall back to plain symbol-table mocking. (GH #93) + return if ref($meta) eq 'Moo::HandleMoose::FakeMetaClass'; return $meta if $meta->isa('Class::MOP::Class'); # Moose return $meta if $meta->isa('Mouse::Meta::Class'); # Mouse return; diff --git a/t/moo_basic.t b/t/moo_basic.t new file mode 100644 index 0000000..979401d --- /dev/null +++ b/t/moo_basic.t @@ -0,0 +1,52 @@ +use strict; +use warnings; +use Test::More; + +BEGIN { + eval { require Moo; 1 } or plan skip_all => "Moo not installed"; +} + +use Test::MockModule; + +{ + package Issue93::MooClass; ## no critic (Modules::RequireFilenameMatchesPackage) + use Moo; + has answer => (is => 'ro', default => 42); + sub greet { 'real_greet' } +} + +{ + package Issue93::MooParent; ## no critic (Modules::RequireFilenameMatchesPackage) + use Moo; + sub bar { 'parent_bar' } +} +{ + package Issue93::MooChild; ## no critic (Modules::RequireFilenameMatchesPackage) + use Moo; + extends 'Issue93::MooParent'; +} + +# Basic mock-and-call +my $mock = Test::MockModule->new('Issue93::MooClass'); +$mock->mock( greet => sub { 'mocked_greet' } ); +is(Issue93::MooClass->new->greet, 'mocked_greet', "Moo mock visible"); +is(Issue93::MooClass->new->answer, 42, "Moo accessor unaffected by mock"); + +$mock->unmock('greet'); +is(Issue93::MooClass->new->greet, 'real_greet', "Moo unmock restores original"); + +# Moo-generated accessors are ordinary subs in the symbol table, so plain +# replacement mocking must work on them too. +$mock->mock( answer => sub { 99 } ); +is(Issue93::MooClass->new->answer, 99, "Moo accessor mock visible"); +$mock->unmock('answer'); +is(Issue93::MooClass->new->answer, 42, "Moo accessor unmock restores original"); + +# Inherited method mock-and-restore +my $imock = Test::MockModule->new('Issue93::MooChild'); +$imock->mock( bar => sub { 'mocked_bar' } ); +is(Issue93::MooChild->bar, 'mocked_bar', "Moo inherited mock visible"); +$imock->unmock('bar'); +is(Issue93::MooChild->bar, 'parent_bar', "Moo unmock falls through to parent"); + +done_testing; diff --git a/t/moo_meta_for.t b/t/moo_meta_for.t new file mode 100644 index 0000000..ea3f8d4 --- /dev/null +++ b/t/moo_meta_for.t @@ -0,0 +1,36 @@ +use strict; +use warnings; +use Test::More; + +BEGIN { + eval { require Moo; 1 } or plan skip_all => "Moo not installed"; +} + +use Test::MockModule; + +# Plain (non-Moo) package +{ + package PlainPkg; ## no critic (Modules::RequireFilenameMatchesPackage) + sub new { bless {}, shift } +} + +# Moo package +{ + package MooPkg; ## no critic (Modules::RequireFilenameMatchesPackage) + use Moo; + has answer => (is => 'ro', default => 42); + sub greet { 'real_greet' } +} + +# A Moo class must NOT be treated as a Moose/Mouse MOP class: _meta_for +# returns undef so mocking falls back to plain symbol-table replacement. +# Pre-fix this either returns a truthy Moo::HandleMoose::FakeMetaClass +# (Moose installed) or throws inside Moo::HandleMoose (Moose absent). +is(Test::MockModule::_meta_for('MooPkg'), undef, "Moo package returns undef meta"); + +is(Test::MockModule::_meta_for('PlainPkg'), undef, "plain package returns undef"); +is(Test::MockModule::_meta_for(undef), undef, "undef package returns undef"); +is(Test::MockModule::_meta_for(''), undef, "empty package returns undef"); +is(Test::MockModule::_meta_for('Does::Not::Exist'), undef, "nonexistent package returns undef"); + +done_testing;