如何模拟 Laravel 模型以进行课程测试?

Posted

技术标签:

【中文标题】如何模拟 Laravel 模型以进行课程测试?【英文标题】:How to mock Laravel model for a test of a class? 【发布时间】:2021-11-25 02:14:55 【问题描述】:

我有这样的课:

<?php 
use App\Models\Product;

class ProductSearcher

    public function getPrintedProducts( $releasedOnly = true ) 
        $products = Product::with('productVersion','productVersion.tag')
                      ->where('printed', 1);
        if ($releasedOnly)
            $products->where('released', 1);
        $products = $products->get();
        return $products;
    

在我的测试中,我写

// ... some other testing ...
public function testChossingCorrectingProductFromProductVersionIds()

    $productSearcher= new ProductSearcher;

    $productMock = \Mockery::mock( 'App\Models\Product' );
    $productMock->shouldReceive( 'with->where->where->get' )->andReturn( [] );

    $this->assertEmpty( $productSearcher->getPrintedProducts( true ) );

// ... some other testing ...

我运行测试但得到:

PHP Fatal error:  Cannot redeclare Mockery_1_App_Models_Product::mockery_init()

请问我做错了什么?或者测试这个功能的正确方法是什么? (这是一个单元测试,不应该太重,所以连接到测试数据库不是一个选项)

【问题讨论】:

你能解释一下你想测试什么吗?你是想测试表是空的还是函数工作正常还是什么? @cytsunny 我已经更新了我的答案,如果现在更好并解决了你的问题,请告诉我。 【参考方案1】:

imo,测试这种功能的最佳方法是在内存中设置一个测试数据库。然后,当它为空时,在测试期间使用工厂创建几个模型。然后你就知道你有多少数据库记录以及什么样的记录。然后检查类是否返回正确的。

第二种方法是使用 IoC - 重构 ProductSearcher 并将 Product 模型类作为依赖项传递。然后您可以在测试期间轻松模拟课程。

但我还有另一个问题 - 为什么首先创建 ProductSearcher 类?改用作用域不是更容易吗?

如果你想使用工厂,可以这样做:

首先 - 定义工厂。如果需要,用于主模型及其关系。为方便起见,您可以使用状态在记录中创建预定义的值集。 https://laravel.com/docs/8.x/database-testing#defining-model-factories

第二 - 创建一个测试。我的例子:

  public function testChossingCorrectingProductFromProductVersionIds()  
   
  $productSearcher= new ProductSearcher(); 

  $notPrinted = Product::factory->count(2)->create([  
      'printed' => false,  
      'released' => false  
  ]);  

  $printed = Product::factory->count(3)->create([  
      'printed' => true,  
      'released' => false  
  ]);   

  $releasedNotPrinted = Product::factory->count(4)->create([  
      'printed' => false,  
      'released' => true  
  ]);

  $releasedPrinted = Product::factory->count(5)->create([  
      'printed' => true,  
      'released' => true  
  ]);  

  $this->assertCount(8, $productSearcher->getPrintedProducts( false ) );  
  $this->assertCount(5, $productSearcher->getPrintedProducts( true ));  

一些开发人员对“一个测试,一个断言”过于敏感,但我不是其中之一,正如你所看到的:D

【讨论】:

很好的答案,但与“一些开发人员对一个测试一个断言过度敏感”有关,我们并没有过度敏感......一个测试 ONLY 测试一个东西,为什么?因为如果第一个断言失败,与其他东西相关的第二个断言将永远不会被测试,直到你修复前一个断言,所以你可能有 2 个失败的测试而不是 1 个......另外,你可能正在设置不应该设置的数据,在你的情况下,你正在创造你不应该拥有的东西。请记住,测试应该只具有与真实案例相同的上下文中的最小数据设置...您“过度”创建...这是一个不规则 作为一般经验法则,我完全同意。我的解决方案简短而简单——仅此而已。我知道它可能(有人甚至可能说“应该”)写成单独的测试,正如您在答案中提出的那样。写“一些开发人员对一个测试一个断言过于敏感”我想到了测试中的一个断言语句,恕我直言,不切实际。顺便说一句 - 我认为你的解决方案更好:)【参考方案2】:

你必须使用factory 来“模拟”模型,你不模拟核心类,你只是伪造它们。您必须使用当时预期拥有的数据创建一个或多个模型。

为了正确测试你的代码,你应该有这样的代码:

namespace Tests\Unit;

use App\Models\Product;
use App\Models\ProductVersion;
use App\Models\Tag;
use App\ProductSearcher;
use Tests\TestCase;

class ProductSearcherTest extends TestCase

    public function test_correct_products_get_returned_when_released_only_is_true(): void
    
        $firstProduct = Product::factory()
            ->has(
                ProductVersion::factory()
                    ->has(Tag::factory())
            )
            ->create([
                'printed' => 1,
                'released' => 1,
            ]);

        $secondProduct = Product::factory()
            ->has(
                ProductVersion::factory()
                    ->has(Tag::factory())
            )
            ->create([
                'printed' => 1,
                'released' => 1,
            ]);

        Product::factory()
            ->has(
                ProductVersion::factory()
                    ->has(Tag::factory())
            )
            ->create([
                'printed' => 1,
                'released' => 0,
            ]);

        Product::factory()
            ->has(
                ProductVersion::factory()
                    ->has(Tag::factory())
            )
            ->create([
                'printed' => 0,
                'released' => 0,
            ]);

        $products = (new ProductSearcher)->getPrintedProducts(true);

        $this->assertCount(2, $products);

        $this->assertTrue(
            $products->first()->is($firstProduct),
            'The first product is not the expected one.'
        );
        $this->assertTrue(
            $products->first()->productVersion->is($firstProduct->productVersion),
            'The first product does not have the expected Product Version associated.'
        );
        $this->assertTrue(
            $products->first()->productVersion->tag->is($firstProduct->productVersion->tag),
            'The first product does not have the expected Tag associated.'
        );

        $this->assertTrue(
            $products->last()->is($secondProduct),
            'The last product is not the expected one.'
        );
        $this->assertTrue(
            $products->last()->productVersion->is($secondProduct->productVersion),
            'The last product does not have the expected Product Version associated.'
        );
        $this->assertTrue(
            $products->last()->productVersion->tag->is($secondProduct->productVersion->tag),
            'The last product does not have the expected Tag associated.'
        );
    

    public function test_correct_products_get_returned_when_released_only_is_false(): void
    
        $firstProduct = Product::factory()
            ->has(
                ProductVersion::factory()
                    ->has(Tag::factory())
            )
            ->create([
                'printed' => 1,
                'released' => 0,
            ]);

        $secondProduct = Product::factory()
            ->has(
                ProductVersion::factory()
                    ->has(Tag::factory())
            )
            ->create([
                'printed' => 1,
                'released' => 0,
            ]);

        Product::factory()
            ->has(
                ProductVersion::factory()
                    ->has(Tag::factory())
            )
            ->create([
                'printed' => 1,
                'released' => 1,
            ]);

        Product::factory()
            ->has(
                ProductVersion::factory()
                    ->has(Tag::factory())
            )
            ->create([
                'printed' => 0,
                'released' => 1,
            ]);

        $products = (new ProductSearcher)->getPrintedProducts(true);

        $this->assertCount(2, $products);

        $this->assertTrue(
            $products->first()->is($firstProduct),
            'The first product is not the expected one.'
        );
        $this->assertTrue(
            $products->first()->productVersion->is($firstProduct->productVersion),
            'The first product does not have the expected Product Version associated.'
        );
        $this->assertTrue(
            $products->first()->productVersion->tag->is($firstProduct->productVersion->tag),
            'The first product does not have the expected Tag associated to the Product Version.'
        );

        $this->assertTrue(
            $products->last()->is($secondProduct),
            'The last product is not the expected one.'
        );
        $this->assertTrue(
            $products->last()->productVersion->is($secondProduct->productVersion),
            'The last product does not have the expected Product Version associated.'
        );
        $this->assertTrue(
            $products->last()->productVersion->tag->is($secondProduct->productVersion->tag),
            'The last product does not have the expected Tag associated to the Product Version.'
        );
    

让我解释一下:

    我们有 2 个测试,因为 ProductSearcher 的唯一参数变化是 truefalse。 因此,对于第一个测试,我们创建了具有released = 1 的模型,但我们也创建了一些具有released = 0 的模型,因此我们确保这些模型也不会被获取。我们还创建了至少一个released = 0,所以我们也没有得到那个。 对于第二个测试,相同但相反(仅反转 released)。我们希望确保我们得到所有具有printed = 1released = 0 的内容。 我们不需要使用这些模型变体创建 4 个测试,因为我们依赖于方法参数,而不是模型属性: 我们只依赖$releasedOnly = true -> 型号:printed = 1, released = 1$releasedOnly = false -> 型号:printed = 1, released = 0。 我们添加了具有不同变体的其他过滤器,以确保我们的过滤器按预期工作。 我还在检查我们是否获得了所需的模型,不仅如此,我还在检查我们是否获得了预期的关系,我确保我得到的正是我所期望的。这就是我创建ProductVersionTag 模型的原因,正如我们所期望的那样。 从不在同一个测试中一次测试多个事物(我们正在测试多个事物,但我指的是实际代码,而不是模型具有 20 个所需属性的情况)。您必须始终准确地测试您要断言/检查的内容。您将看到一个创建所有内容并测试两种变体的答案,这是不需要的,因为您要确保得到您期望的内容,您必须编写巨大的断言来测试所有内容,我们这样做了,但是在 2 个不同的测试中,它更具可读性(但在这种情况下更长),但您可以确保获得 100% 预期的机器人。

【讨论】:

工厂好像只用来创建模型对象?如何测试Product::get() 之类的方法? (或者像我在问题中给出的那样稍微复杂一点的功能?) 你不测试getwith等。那是核心代码,你不测试核心代码,因为它有效。您应该拥有一个带有printed = true 的模型和另一个带有false 的模型,并对其进行测试以返回所需的模型。然后与released 相同。但是你在测试框架,你从不测试框架,你测试你自己的代码和逻辑。 你说得对,我要测试的主要内容是printed 的逻辑。那么,我可以举一个关于我应该如何测试getPrintedProducts() 的例子吗?还是我需要重构其他函数来测试它? 已更新,如果现在有更好的帮助,请告诉我,有什么不明白的请问我

以上是关于如何模拟 Laravel 模型以进行课程测试?的主要内容,如果未能解决你的问题,请参考以下文章

Laravel 5 - 使用 Mockery 模拟 Eloquent 模型

如何在 laravel 5 中模拟模型上的 create 方法?

如何在作曲家包开发测试中模拟用户模型?

如何在 PHPUnit / Laravel 中模拟 JSON 文件

如何模拟 Laravel Eloquent 模型的静态方法?

在 PHPUnit 中调用路由时如何在 Laravel 8 中模拟 Eloquent 模型