orchestral/testbench

Testbench incorrectly setting application namespace to "App\"

DigitalMachinist opened this issue · 4 comments

  • Testbench Version: v7.23.0
  • Laravel Version: v9.52.5
  • PHP Version: v8.1.17
  • Database Driver & Version: mysql Ver 8.0.32-0ubuntu0.20.04.2 for Linux on x86_64 ((Ubuntu))

Description:

I'm not sure that this is a bug, but I can't find any other useful sources about this, so here I am. I'm a long-time PHP/Laravel dev but this is my first time using testbench and doing package development.

I am developing a Laravel package that does not exist inside a full Laravel app skeleton -- its just requiring orchestral/testbench. I have configured my composer.json to map my package namespace to my package root src/:

    "autoload": {
        "psr-4": {
            "MyCompany\\MyPackage\\Data\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "MyCompany\\MyPackage\\Data\\Tests\\": "tests/"
        }
    },
    "require": {
        "php": "^8.1",
        "illuminate/contracts": "^9",
        "ramsey/uuid": "^4.6",
        "spatie/laravel-binary-uuid": "1.3.3",
        "spatie/laravel-package-tools": "^1.14.0"
    },
    "require-dev": {
        "laravel/pint": "^1.0",
        "nunomaduro/collision": "^6",
        "nunomaduro/larastan": "^2.0.1",
        "orchestra/testbench": "^7",
        "phpstan/phpstan-phpunit": "^1.0",
        "phpunit/phpunit": "^9",
        "spatie/laravel-ray": "^1.26"
    },

However, when I run ./vendor/bin/phpunit, I get the following error when using a factory to create any model:

Error: Class "App\ModelName" not found

Obviously, this doesn't agree with the namespace I have given. However, my static analysis tools respect the namespace I've provided in composer.json. It seems like within my package, the namespace I have given is being handled correctly, but not by the Laravel Application instance.

I've traced this back through how this namespace gets set, and it turns out that the Application instance is checking the composer.json file of laravel/laravel for this value -- not the namespace I've given in the root of my package. I dumped out the contents of the composer.json that Application is reading when I try to create a model:

array:10 [ // /mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Foundation/Application.php:1416
  "name" => "laravel/laravel"
  "description" => "The Laravel Framework."
  "keywords" => array:2 [
    0 => "framework"
    1 => "laravel"
  ]
  "license" => "MIT"
  "type" => "project"
  "require" => array:1 [
    "laravel/framework" => "~5.0"
  ]
  "require-dev" => array:1 [
    "phpunit/phpunit" => "~4.0"
  ]
  "autoload" => array:2 [
    "classmap" => array:2 [
      0 => "database"
      1 => "tests/TestCase.php"
    ]
    "psr-4" => array:1 [
      "App\" => "app/"
    ]
  ]
  "extra" => array:1 [
    "laravel" => array:1 [
      "dont-discover" => []
    ]
  ]
  "minimum-stability" => "dev"
]

Which makes sense if you're using laravel/laravel as a project skeleton, but not for package development.

As far as I'm aware, I'm following all of the normal practices for testing a Laravel package using testbench, and I can't find any other cases on google for this issue I'm having. And yet... I can't see how this error shouldn't happen to everyone.

What am I doing wrong here?

Steps To Reproduce:

  1. Make a new composer package and set up composer.json with the package namespace as above.
  2. Add database models, factories and tests to run against those models.
  3. composer require --dev orchestral/testbench:^7 phpunit/phpunit:^9
  4. composer install
  5. ./vendor/bin/phpunit

Add actual reproducing code.

Sure. Does this give you enough to work with?

I can run ./vendor/bin/phpunit --filter=testOperatorHasManySessionsRelationship and reproduce the issue with the setup described above.

The exact phpunit output is:

❯ ./vendor/bin/phpunit --filter=testOperatorHasManySessionsRelationship
PHPUnit 9.6.6 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 00:05.740, Memory: 28.00 MB

There was 1 error:

1) MyCompany\MyPackage\Data\Tests\Models\Operator\OperatorTest::testOperatorHasManySessionsRelationship
Error: Class "App\Operator" not found

/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:767
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:412
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php:155
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:418
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:385
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:279
/mnt/c/Users/jrose/Workspace/git/my-package-name/tests/Unit/Models/Operator/OperatorTest.php:18

tests/Unit/Models/Operator/Operator.php

namespace MyCompany\MyPackage\Data\Tests\Models\Operator;

use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Testing\RefreshDatabase;
use MyCompany\MyPackage\Data\Models\Operator\Operator;
use MyCompany\MyPackage\Data\Models\Session\Session;
use MyCompany\MyPackage\Data\Tests\TestCase;

class OperatorTest extends TestCase
{
    use RefreshDatabase;

    public function testOperatorHasManySessionsRelationship(): void
    {
        $count = 2;
        $operator = Operator::factory()->create();
        Session::factory()->count($count)->create([
            'operator_uuid' => $operator->uuid,
        ]);

        $this->assertInstanceOf(HasMany::class, $operator->sessions());
        $this->assertCount($count, $operator->sessions()->get());
    }
}

tests/TestCase.php

namespace MyCompany\MyPackage\Data\Tests;

use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase as Orchestra;

class TestCase extends Orchestra
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        $this->loadMigrationsFrom(__DIR__ . '/database/migrations');

        Factory::guessFactoryNamesUsing(
            fn (string $modelName) => 'MyCompany\\MyPackage\\Data\\Database\\Factories\\'.class_basename($modelName).'\\'.class_basename($modelName).'Factory'
        );
    }

    protected function getPackageProviders($app)
    {
        return [
            \MyCompany\MyPackage\Data\GamesServiceCoreDataModelServiceProvider::class,
            \MyCompany\MyPackage\Data\Services\ApiKey\ApiKeyServiceProvider::class,
            \MyCompany\MyPackage\Data\Services\DatabaseConnection\DatabaseConnectionServiceProvider::class,
            \MyCompany\MyPackage\Data\Services\Lock\LockServiceProvider::class,
            \MyCompany\MyPackage\Data\Services\OperatorModel\OperatorModelServiceProvider::class,
        ];
    }

    public function getEnvironmentSetUp($app)
    {
        // Make sure our .env file is loaded.
        $app->useEnvironmentPath(__DIR__.'/..');
        $app->bootstrapWith([LoadEnvironmentVariables::class]);

        parent::getEnvironmentSetUp($app);

        // Use mysql as our database connection.
        config()->set('database.default', 'mysql');
        config()->set('database.connections.mysql', [
            'driver'   => 'mysql',
            'database' => env('DATABASE_NAME', 'testing'),
            'host'     => env('DATABASE_HOST', '127.0.0.1'),
            'username' => env('DATABASE_USERNAME', ''),
            'password' => env('DATABASE_PASSWORD', ''),
        ]);
    }
}

src/Models/Operator/Operator.php

namespace MyCompany\MyPackage\Data\Models\Operator;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable;
use MyCompany\MyPackage\Data\Models\Session\Session;
use MyCompany\MyPackage\Data\Models\TransactionRequest\TransactionRequest;
use MyCompany\MyPackage\Data\Traits\SerializeBinaryUuids;
use Serializable;
use Spatie\BinaryUuid\HasBinaryUuid;

final class Operator extends Model implements Serializable
{
    use HasBinaryUuid;
    use HasFactory;
    use Notifiable;
    use SerializeBinaryUuids;

    public const DEMO_OPERATOR_NAME = 'Demo';

    //
    //  Settings
    //

    protected $primaryKey = 'uuid';
    protected $keyType = 'string';
    protected $dateFormat = 'c';
    public $timestamps = true;

    /**
     * @var array<int, string>|null
     */
    protected $binaryUuidFields = [
        'uuid',
    ];

    /**
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'contact_email',
        'base_url',
    ];

    /**
     * @var array<int, string>
     */
    protected $hidden = [
        'uuid',
        'secret',
    ];

    /**
     * @var array<int, string>
     */
    protected $dates = [
        'started_at',
        'ended_at',
        'created_at',
        'updated_at',
    ];

    /**
     * @var array<string, string>
     */
    protected $casts = [
        'status' => OperatorStatus::class,
    ];

    //
    // Boot
    //

    public static function boot(): void
    {
        parent::boot();
        self::observe(new OperatorObserver());
    }

    //
    // Custom Builder & Collection
    //

    /**
     * @param  \Illuminate\Database\Query\Builder  $query
     * @return OperatorBuilder
     */
    public function newEloquentBuilder($query): OperatorBuilder
    {
        return new OperatorBuilder($query);
    }

    /**
     * @param  array<int, Operator>  $models
     * @return OperatorCollection
     */
    public function newCollection(array $models = []): OperatorCollection
    {
        return new OperatorCollection($models);
    }

    //
    //  Relationships
    //

    /**
     * @return HasMany<TransactionRequest>
     */
    public function transaction_requests(): HasMany
    {
        return $this->hasMany(TransactionRequest::class);
    }

    /**
     * @return HasMany<Session>
     */
    public function sessions(): HasMany
    {
        return $this->hasMany(Session::class);
    }

database/factories/Operator/OperatorFactory.php

namespace MyCompany\MyPackage\Data\Database\Factories\Operator;

use MyCompany\MyPackage\Data\Models\Operator\Operator;
use MyCompany\MyPackage\Data\Models\Operator\OperatorStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\MyCompany\MyPackage\Data\Models\Operator\Operator>
 */
class OperatorFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition()
    {
        return [
            'name'          => Str::limit(fake()->company(), 16, ''),
            'status'        => OperatorStatus::ENABLED,
            'contact_email' => fake()->email(),
            'base_url'      => url('api'),
            'secret'        => app(\MyCompany\MyPackage\Data\Services\ApiKey\ApiKeyServiceContract::class)->generate(64),
        ];
    }

    /**
     * Indicate that the model's status should be set to 'disabled'.
     *
     * @return static
     */
    public function disabled()
    {
        return $this->state(fn (array $attributes) => [
            'status' => OperatorStatus::DISABLED,
        ]);
    }

    /**
     * Indicate that the model's status should be set to 'disabled'.
     *
     * @return static
     */
    public function enabled()
    {
        return $this->state(fn (array $attributes) => [
            'status' => OperatorStatus::ENABLED,
        ]);
    }

    /**
     * Indicate that the model's status should be set to 'disabled'.
     *
     * @return static
     */
    public function demo()
    {
        return $this->state(fn (array $attributes) => [
            'name'          => Operator::DEMO_OPERATOR_NAME,
            'contact_email' => 'gamesdemo@mycompany.com',
            'base_url'      => 'http://localhost',
            'secret'        => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
        ]);
    }
}
Error: Class "App\Operator" not found

/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:767
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:412
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php:155
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:418
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:385
/mnt/c/Users/jrose/Workspace/git/my-package-name/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php:279

Clearly, from above it was caused by Eloquent Factory and not Testbench. Testbench doesn't mess with any of the factory and would recommend using UserFactory::new()->create() instead of User::factory()->create().

See https://github.com/orchestral/testbench-core/blob/8.x/src/Factories/UserFactory.php

As it turns out, it makes no difference whether I use Operator::factory() or OperatorFactory::new() but what does make a difference is explicitly defining the model type in the factory as you've done in UserFactory there: https://github.com/orchestral/testbench-core/blob/8.x/src/Factories/UserFactory.php#L20

Good enough to unblock me for now! Thank you for linking that. This might be a helpful thing to mention in docs as its something of a gotcha if you use model factories at all for testing.