├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── phpunit.xml ├── pint.json ├── src ├── FactoryGenerator.php ├── FactoryGeneratorServiceProvider.php ├── GenerateCommand.php └── TypeGuesser.php ├── stubs └── factory.stub └── tests ├── Fixtures └── Models │ ├── Book.php │ ├── Car.php │ ├── Habit.php │ ├── NotAModel.php │ └── User.php ├── GenerateCommandTest.php ├── TestCase.php ├── TypeGuesserTest.php └── migrations ├── 0000_00_00_000001_create_users_table.php ├── 0000_00_00_000002_create_habits_table.php └── 0000_00_00_000003_create_cars_table.php /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /vendor 3 | .env 4 | .phpunit.result.cache 5 | composer.lock 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Laravel Shift 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Model Factory Generator 2 | This package generates model factories from existing models using the new [class-based factories](https://laravel.com/docs/8.x/database-testing#writing-factories) introduced in Laravel 8. 3 | 4 | 5 | ## Installation 6 | You may install this package via composer by running: 7 | 8 | ```sh 9 | composer require --dev laravel-shift/factory-generator 10 | ``` 11 | 12 | The package will automatically register itself using Laravel's package discovery. 13 | 14 | 15 | ## Usage 16 | This package adds an artisan command for generating model factories. 17 | 18 | Without any arguments, this command will generate model factories for all existing models within your Laravel application: 19 | 20 | ```sh 21 | php artisan generate:factory 22 | ``` 23 | 24 | Similar to Laravel, this will search for models within the `app/Models` folder, or if that folder does not exist, within the `app` folder. 25 | 26 | To generate factories for models within a different folder, you may pass the `--path` option (or `-p`). 27 | 28 | ```sh 29 | php artisan generate:factory --path=some/Other/Path 30 | ``` 31 | 32 | To generate a factory for a single model, you may pass the model name: 33 | 34 | ```sh 35 | php artisan generate:factory User 36 | ``` 37 | 38 | By default _nullable_ columns are not included in the factory definition. If you want to include _nullable_ columns you may set the `--include-nullable` option (or `-i`). 39 | 40 | ```sh 41 | php artisan generate:factory -i User 42 | ``` 43 | 44 | 45 | ## Attribution 46 | This package was original forked from [Naoray/laravel-factory-prefill](https://github.com/Naoray/laravel-factory-prefill) by [Krishan König](https://github.com/Naoray). 47 | 48 | It has diverged to support the latest version of Laravel and to power part of the automation by the [Tests Generator](https://laravelshift.com/laravel-test-generator). 49 | 50 | 51 | ## Contributing 52 | Contributions should be submitted to the `master` branch. Any submissions should be complete with tests and adhere to the [Laravel code style](https://www.php-fig.org/psr/psr-2/). You may also contribute by [opening an issue](https://github.com/laravel-shift/factory-generator/issues). 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-shift/factory-generator", 3 | "description": "Generate factories from existing models", 4 | "type": "package", 5 | "license": "MIT", 6 | "keywords": [ 7 | "laravel", 8 | "factory", 9 | "model", 10 | "testing" 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "illuminate/support": "^10.0|^11.0|^12.0", 15 | "fakerphp/faker": "^1.9.1" 16 | }, 17 | "require-dev": { 18 | "orchestra/testbench": "^8.0|^9.0|^10.0", 19 | "phpunit/phpunit": "^10.5|^11.5.3" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Shift\\FactoryGenerator\\": "src" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Tests\\": "tests", 29 | "App\\": "vendor/orchestra/testbench-core/laravel/app" 30 | }, 31 | "exclude-from-classmap": [ 32 | "tests/migrations" 33 | ] 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "providers": [ 38 | "Shift\\FactoryGenerator\\FactoryGeneratorServiceProvider" 39 | ] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | src/ 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "notPaths": [] 4 | } 5 | -------------------------------------------------------------------------------- /src/FactoryGenerator.php: -------------------------------------------------------------------------------- 1 | typeGuesser = $guesser; 24 | $this->includeNullableColumns = $nullables; 25 | $this->overwrite = $overwrite; 26 | } 27 | 28 | public function generate($model): ?string 29 | { 30 | if (! $modelClass = $this->modelExists($model)) { 31 | return null; 32 | } 33 | 34 | $factoryPath = $this->factoryPath($modelClass); 35 | 36 | if (! $this->overwrite && $this->factoryExists($factoryPath)) { 37 | return null; 38 | } 39 | 40 | $this->modelInstance = new $modelClass(); 41 | 42 | $code = Artisan::call('model:show', ['model' => $modelClass, '--json' => true]); 43 | if ($code !== 0) { 44 | return null; 45 | } 46 | 47 | $json = json_decode(Artisan::output(), true); 48 | if (! $json) { 49 | return null; 50 | } 51 | 52 | $foreign_keys = $this->modelInstance->getConnection()->getSchemaBuilder()->getForeignKeys($json['table']); 53 | 54 | collect($this->columns($json['attributes'], $foreign_keys)) 55 | ->merge($this->relationships($json['relations'])) 56 | ->filter() 57 | ->unique() 58 | ->values() 59 | ->pipe(function ($properties) use ($factoryPath, $modelClass) { 60 | $this->writeFactoryFile($factoryPath, $properties->all(), $modelClass); 61 | $this->addFactoryTrait($modelClass); 62 | }); 63 | 64 | return $factoryPath; 65 | } 66 | 67 | protected function addFactoryTrait($modelClass) 68 | { 69 | $traits = class_uses_recursive($modelClass); 70 | if (in_array('Illuminate\\Database\\Eloquent\\Factories\\HasFactory', $traits)) { 71 | return; 72 | } 73 | 74 | $path = (new \ReflectionClass($modelClass))->getFileName(); 75 | 76 | $contents = File::get($path); 77 | 78 | $tokens = collect(\PhpToken::tokenize($contents)); 79 | 80 | $class = $tokens->first(fn (\PhpToken $token) => $token->id === T_CLASS); 81 | $import = $tokens->first(fn (\PhpToken $token) => $token->id === T_USE); 82 | 83 | $pos = strpos($contents, '{', $class->pos) + 1; 84 | $replacement = PHP_EOL.' use HasFactory;'.PHP_EOL; 85 | $contents = substr_replace($contents, $replacement, $pos, 0); 86 | 87 | $anchor = $import ?? $class; 88 | 89 | $contents = substr_replace( 90 | $contents, 91 | 'use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;'.PHP_EOL, 92 | $anchor->pos, 93 | 0 94 | ); 95 | 96 | File::put($path, $contents); 97 | } 98 | 99 | /** 100 | * Check if factory already exists. 101 | * 102 | * @param string $name 103 | * @return bool|string 104 | */ 105 | protected function factoryExists($path): bool 106 | { 107 | return File::exists($path); 108 | } 109 | 110 | /** 111 | * Map database table column definition to faker value. 112 | */ 113 | protected function mapColumn(array $column): array 114 | { 115 | $key = $column['name']; 116 | 117 | if (! $this->shouldBeIncluded($column)) { 118 | return $this->factoryTuple($key); 119 | } 120 | 121 | // TODO: probably belongs elsewhere... 122 | if ($key === 'password') { 123 | return $this->factoryTuple($key, "Hash::make('password')"); 124 | } 125 | 126 | $value = $column['unique'] 127 | ? '$this->faker->unique()->' 128 | : '$this->faker->'; 129 | 130 | return $this->factoryTuple($key, $value.$this->mapToFaker($column)); 131 | } 132 | 133 | protected function factoryTuple($key, $value = null): array 134 | { 135 | return [ 136 | $key => is_null($value) ? $value : "'{$key}' => $value", 137 | ]; 138 | } 139 | 140 | /** 141 | * Map name to faker method. 142 | */ 143 | protected function mapToFaker(array $column): string 144 | { 145 | return $this->typeGuesser->guess( 146 | $column['name'], 147 | $column['type'], 148 | $column['length'] 149 | ); 150 | } 151 | 152 | /** 153 | * Check if the given model exists. 154 | */ 155 | protected function modelExists(string $name): string 156 | { 157 | if (class_exists($modelClass = $this->qualifyClass($name))) { 158 | return $modelClass; 159 | } 160 | 161 | // TODO: this check should happen before calling this service class... 162 | throw new \UnexpectedValueException('could not find model ['.$name.']'); 163 | } 164 | 165 | /** 166 | * Parse the class name and format according to the root namespace. 167 | */ 168 | protected function qualifyClass(string $name): string 169 | { 170 | $name = ltrim($name, '\\/'); 171 | 172 | $rootNamespace = app()->getNamespace(); 173 | 174 | if (Str::startsWith($name, $rootNamespace)) { 175 | return $name; 176 | } 177 | 178 | $name = str_replace('/', '\\', $name); 179 | 180 | return $this->qualifyClass( 181 | trim($rootNamespace, '\\').'\\'.$name 182 | ); 183 | } 184 | 185 | /** 186 | * Get properties for relationships where we can build 187 | * other factories. Currently, that's simply BelongsTo. 188 | */ 189 | protected function relationships(array $relationships): Collection 190 | { 191 | return collect($relationships) 192 | ->filter(fn ($relationship) => $relationship['type'] === 'BelongsTo') 193 | ->mapWithKeys(function ($relationship) { 194 | $property = $this->modelInstance->{$relationship['name']}()->getForeignKeyName(); 195 | 196 | return [$property => "'$property' => \\".$relationship['related'].'::factory()']; 197 | }); 198 | } 199 | 200 | /** 201 | * Check if a given column should be included in the factory. 202 | */ 203 | protected function shouldBeIncluded(array $column): bool 204 | { 205 | $shouldBeIncluded = (! $column['nullable'] || $this->includeNullableColumns) 206 | && ! $column['increments'] 207 | && ! $column['foreign'] 208 | && $column['name'] !== $this->modelInstance->getKeyName(); 209 | 210 | if (! $this->modelInstance->usesTimestamps()) { 211 | return $shouldBeIncluded; 212 | } 213 | 214 | $timestamps = [ 215 | $this->modelInstance->getCreatedAtColumn(), 216 | $this->modelInstance->getUpdatedAtColumn(), 217 | ]; 218 | 219 | if (method_exists($this->modelInstance, 'getDeletedAtColumn')) { 220 | $timestamps[] = $this->modelInstance->getDeletedAtColumn(); 221 | } 222 | 223 | return $shouldBeIncluded 224 | && ! in_array($column['name'], $timestamps); 225 | } 226 | 227 | /** 228 | * Write the model factory file using the given definition and path. 229 | */ 230 | protected function writeFactoryFile(string $path, array $data, string $modelClass): void 231 | { 232 | File::ensureDirectoryExists(dirname($path)); 233 | 234 | $factoryQualifiedName = \Illuminate\Database\Eloquent\Factories\Factory::resolveFactoryName($modelClass); 235 | $factoryNamespace = Str::beforeLast($factoryQualifiedName, '\\'); 236 | $contents = File::get(__DIR__.'/../stubs/factory.stub'); 237 | $contents = str_replace('{{ factoryNamespace }}', $factoryNamespace, $contents); 238 | $contents = str_replace('{{ namespacedModel }}', $modelClass, $contents); 239 | $contents = str_replace('{{ model }}', class_basename($modelClass), $contents); 240 | $definitions = array_map(fn ($value) => ' '.$value.',', $data); 241 | $contents = str_replace(' //', implode(PHP_EOL, $definitions), $contents); 242 | 243 | File::put($path, $contents); 244 | } 245 | 246 | private function appendColumnData(array $column, array $foreignKeys): array 247 | { 248 | $column['foreign'] = in_array($column['name'], $foreignKeys); 249 | $column['length'] = null; 250 | 251 | if (str_contains($column['type'], '(')) { 252 | $column['length'] = Str::between($column['type'], '(', ')'); 253 | $column['type'] = Str::before($column['type'], '('); 254 | } 255 | 256 | return $column; 257 | } 258 | 259 | private function columns(array $attributes, array $foreignKeys): Collection 260 | { 261 | return collect($attributes) 262 | ->reject(fn ($column) => is_null($column['type'])) 263 | ->map(fn ($column) => $this->appendColumnData($column, $foreignKeys)) 264 | ->mapWithKeys(fn ($column) => $this->mapColumn($column)); 265 | } 266 | 267 | private function factoryPath($model): string 268 | { 269 | $subDirectory = Str::of($model) 270 | ->replaceFirst('App\\Models\\', '') 271 | ->replaceFirst('App\\', ''); 272 | 273 | return database_path('factories/'.str_replace('\\', '/', $subDirectory).'Factory.php'); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/FactoryGeneratorServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 12 | $this->commands([ 13 | GenerateCommand::class, 14 | ]); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/GenerateCommand.php: -------------------------------------------------------------------------------- 1 | resolveModelPath(); 25 | $models = $this->argument('models'); 26 | 27 | if (! File::exists($directory)) { 28 | $this->error("Path does not exist [$directory]"); 29 | 30 | return self::FAILURE; 31 | } 32 | 33 | $generator = resolve(FactoryGenerator::class, ['nullables' => $this->option('include-nullable'), 'overwrite' => $this->option('force')]); 34 | 35 | $this->loadModels($directory, $models) 36 | ->filter(function ($model) { 37 | $model = new ReflectionClass($model); 38 | 39 | return $model->isSubclassOf(Model::class) && ! $model->isAbstract(); 40 | }) 41 | ->each(function ($model) use ($generator) { 42 | $factory = $generator->generate($model); 43 | 44 | if ($factory) { 45 | $this->line('Model factory created: '.$factory); 46 | } else { 47 | $this->line('Failed to create factory for model: '.$model); 48 | } 49 | }); 50 | 51 | return self::SUCCESS; 52 | } 53 | 54 | protected function loadModels(string $directory, array $models = []): Collection 55 | { 56 | if (! empty($models)) { 57 | $dir = str_replace(app_path(), '', $directory); 58 | 59 | return collect($models)->map(function ($name) use ($dir) { 60 | if (strpos($name, '\\') !== false) { 61 | return $name; 62 | } 63 | 64 | return str_replace( 65 | [DIRECTORY_SEPARATOR, basename($this->laravel->path()).'\\'], 66 | ['\\', $this->laravel->getNamespace()], 67 | basename($this->laravel->path()).$dir.DIRECTORY_SEPARATOR.$name 68 | ); 69 | }); 70 | } 71 | 72 | return collect(File::allFiles($directory))->map(function (SplFileInfo $file) { 73 | if (! preg_match('/^namespace\s+([^;]+)/m', $file->getContents(), $matches)) { 74 | return null; 75 | } 76 | 77 | return $matches[1].'\\'.$file->getBasename('.php'); 78 | })->filter(); 79 | } 80 | 81 | protected function resolveModelPath(): string 82 | { 83 | $path = $this->option('path'); 84 | if (! is_null($path)) { 85 | return base_path($path); 86 | } 87 | 88 | if (File::isDirectory(app_path('Models'))) { 89 | return app_path('Models'); 90 | } 91 | 92 | return app_path(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/TypeGuesser.php: -------------------------------------------------------------------------------- 1 | generator = $generator; 22 | } 23 | 24 | public function guess(string $name, string $type, ?string $size = null): string 25 | { 26 | $name = Str::of($name)->lower(); 27 | 28 | if ($name->endsWith('_id')) { 29 | return 'randomDigitNotNull()'; 30 | } 31 | 32 | if ($typeNameGuess = $this->guessBasedOnName($name->__toString(), $size)) { 33 | return $typeNameGuess; 34 | } 35 | 36 | if ($nativeName = $this->nativeNameFor($name->replace('_', ''))) { 37 | return $nativeName.'()'; 38 | } 39 | 40 | if ($name->endsWith('_url')) { 41 | return 'url()'; 42 | } 43 | 44 | return $this->guessBasedOnType($type, $size); 45 | } 46 | 47 | /** 48 | * Get type guess. 49 | */ 50 | protected function guessBasedOnName(string $name, ?string $size = null): ?string 51 | { 52 | if (str_ends_with($name, '_token')) { 53 | return 'sha1()'; 54 | } 55 | 56 | return match ($name) { 57 | 'login' => 'userName()', 58 | 'email_address', 'emailaddress' => 'email()', 59 | 'phone', 'telephone', 'telnumber' => 'phoneNumber()', 60 | 'town' => 'city()', 61 | 'postalcode', 'postal_code', 'zipcode', 'zip_code' => 'postcode()', 62 | 'province', 'county' => $this->predictCountyType(), 63 | 'country' => $this->predictCountryType($size), 64 | 'currency' => 'currencyCode()', 65 | 'website' => 'url()', 66 | 'companyname', 'company_name', 'employer' => 'company()', 67 | 'title' => $this->predictTitleType($size), 68 | default => null, 69 | }; 70 | } 71 | 72 | /** 73 | * Get native name for the given string. 74 | */ 75 | protected function nativeNameFor(string $lookup): ?string 76 | { 77 | static $fakerMethodNames = []; 78 | 79 | if (empty($fakerMethodNames)) { 80 | $fakerMethodNames = collect($this->generator->getProviders()) 81 | ->flatMap(function (Base $provider) { 82 | return $this->getNamesFromProvider($provider); 83 | }) 84 | ->unique() 85 | ->toArray(); 86 | } 87 | 88 | if (isset($fakerMethodNames[$lookup])) { 89 | return $fakerMethodNames[$lookup]; 90 | } 91 | 92 | return null; 93 | } 94 | 95 | /** 96 | * Get public methods as a lookup pair. 97 | */ 98 | protected function getNamesFromProvider(Base $provider): array 99 | { 100 | return collect(get_class_methods($provider)) 101 | ->reject(fn (string $methodName) => Str::startsWith($methodName, '__')) 102 | ->mapWithKeys(fn (string $methodName) => [Str::lower($methodName) => $methodName]) 103 | ->all(); 104 | } 105 | 106 | /** 107 | * Try to guess the right faker method for the given type. 108 | */ 109 | protected function guessBasedOnType(string $type, ?string $size): string 110 | { 111 | $precision = 0; 112 | if (str_contains($size, ',')) { 113 | [$size, $precision] = explode(',', $size, 2); 114 | } 115 | 116 | $type = match ($type) { 117 | 'tinyint(1)', 'bit', 'varbit', 'boolean', 'bool' => 'boolean', 118 | 'varchar(max)', 'nvarchar(max)', 'text', 'ntext', 'tinytext', 'mediumtext', 'longtext' => 'text', 119 | 'integer', 'int', 'int4', 'smallint', 'int2', 'tinyint', 'mediumint', 'bigint', 'int8' => 'integer', 120 | 'date' => 'date', 121 | 'decimal', 'float', 'real', 'float4', 'double', 'float8' => 'float', 122 | 'time', 'timetz' => 'time', 123 | 'datetime', 'datetime2', 'smalldatetime','datetimeoffset' => 'datetime', 124 | 'timestamp', 'timestamptz' => 'timestamp', 125 | 'json', 'jsonb' => 'json', 126 | 'uuid', 'uniqueidentifier' => 'uuid', 127 | 'inet', 'inet4', 'cidr' => 'ip_address', 128 | 'macaddr', 'macaddr8' => 'mac_address', 129 | 'year' => 'year', 130 | 'char', 'bpchar', 'nchar' => 'char', 131 | 'varchar', 'nvarchar' => 'string', 132 | 'binary', 'varbinary', 'bytea', 'image', 'blob', 'tinyblob', 'mediumblob', 'longblob' => 'binary', 133 | 'geometry', 'geometrycollection', 'linestring', 'multilinestring', 'point', 'multipoint', 'polygon', 'multipolygon' => 'geometry', 134 | 'geography' => 'geography', 135 | 136 | // 'enum => 'enum', 137 | // 'set' => 'set', 138 | // 'money', 'smallmoney' => 'money', 139 | // 'xml' => 'xml', 140 | // 'interval' => 'interval', 141 | // 'box', 'circle', 'line', 'lseg', 'path' => 'geometry', 142 | // 'tsvector', 'tsquery' => 'text', 143 | default => $type, 144 | }; 145 | 146 | if ($type === 'float' && $precision == 0) { 147 | $type = 'integer'; 148 | } 149 | 150 | return match ($type) { 151 | 'boolean' => 'boolean()', 152 | 'char' => 'randomLetter()', 153 | 'date' => 'date()', 154 | 'datetime' => 'dateTime()', 155 | 'float' => 'randomFloat('.$precision.')', 156 | 'inet6' => 'ipv6()', 157 | 'integer', 'number' => 'randomNumber('.$size.')', 158 | 'ip_address' => 'ipv4()', 159 | 'mac_address' => 'macAddress()', 160 | 'text' => 'text()', 161 | 'time' => 'time()', 162 | 'timestamp' => 'unixTime()', 163 | 'uuid' => 'uuid()', 164 | 'year' => 'year()', 165 | default => 'word()', 166 | }; 167 | } 168 | 169 | /** 170 | * Predicts county type by locale. 171 | */ 172 | protected function predictCountyType(): string 173 | { 174 | if ($this->generator->locale == 'en_US') { 175 | return "sprintf('%s County', \$faker->city())"; 176 | } 177 | 178 | return 'state()'; 179 | } 180 | 181 | /** 182 | * Predicts country code based on $size. 183 | */ 184 | protected function predictCountryType(?int $size): string 185 | { 186 | return match ($size) { 187 | 2 => 'countryCode()', 188 | 3 => 'countryISOAlpha3()', 189 | 5, 6 => 'locale()', 190 | default => 'country()', 191 | }; 192 | } 193 | 194 | /** 195 | * Predicts type of title by $size. 196 | */ 197 | protected function predictTitleType(?int $size): string 198 | { 199 | if ($size === null || $size <= 10) { 200 | return 'title()'; 201 | } 202 | 203 | return 'sentence()'; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /stubs/factory.stub: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function definition(): array 16 | { 17 | return [ 18 | // 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Fixtures/Models/Book.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 22 | } 23 | 24 | /** 25 | * Get my previous owner. 26 | * 27 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 28 | */ 29 | public function previousOwner() 30 | { 31 | return $this->belongsTo(User::class); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Fixtures/Models/Habit.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Fixtures/Models/NotAModel.php: -------------------------------------------------------------------------------- 1 | artisan('make:factory', ['name' => 'ExistsFactory']); 21 | $this->artisan('make:model', ['name' => 'Exists']); 22 | 23 | $this->artisan('generate:factory', ['models' => ['Exists']]) 24 | ->expectsOutput('Factory already exists for model [Exists]') 25 | ->run(); 26 | } 27 | 28 | /** @test */ 29 | public function it_asks_if_a_model_shall_be_created_if_it_does_not_yet_exist() 30 | { 31 | $this->artisan('generate:factory', ['models' => ['App\\NonExistent']]) 32 | ->expectsOutput('Model created successfully.') 33 | ->run(); 34 | } 35 | 36 | /** @test */ 37 | public function it_can_associate_models_through_their_relationship() 38 | { 39 | $this->artisan('generate:factory', [ 40 | 'models' => [Habit::class], 41 | '--no-interaction' => true, 42 | ]) 43 | ->expectsOutput('Factory blueprint created!') 44 | ->run(); 45 | 46 | $this->assertFileExists(database_path('factories/HabitFactory.php')); 47 | $this->assertTrue(Str::contains( 48 | File::get(database_path('factories/HabitFactory.php')), 49 | "'user_id' => factory(Tests\Fixtures\Models\User::class)->lazy()," 50 | )); 51 | } 52 | 53 | /** @test */ 54 | public function it_can_associate_models_through_their_relationship_methods_without_touching_the_db() 55 | { 56 | $this->artisan('generate:factory', [ 57 | 'models' => [Car::class], 58 | '--no-interaction' => true, 59 | ]) 60 | ->expectsOutput('Factory blueprint created!') 61 | ->run(); 62 | 63 | $this->assertFileExists(database_path('factories/CarFactory.php')); 64 | $this->assertTrue(Str::contains( 65 | File::get(database_path('factories/CarFactory.php')), 66 | "'owner_id' => factory(Tests\Fixtures\Models\User::class)->lazy()," 67 | )); 68 | } 69 | 70 | /** @test */ 71 | public function it_can_correctly_prefill_password_columns() 72 | { 73 | $this->artisan('generate:factory', [ 74 | 'models' => [User::class], 75 | '--no-interaction' => true, 76 | ]) 77 | ->expectsOutput('Factory blueprint created!') 78 | ->run(); 79 | 80 | $this->assertFileExists(database_path('factories/UserFactory.php')); 81 | $this->assertTrue(Str::contains( 82 | File::get(database_path('factories/UserFactory.php')), 83 | "'password' => bcrypt('password')," 84 | )); 85 | } 86 | 87 | /** @test */ 88 | public function it_can_create_prefilled_factories_for_a_model() 89 | { 90 | $this->artisan('generate:factory', [ 91 | 'models' => [Habit::class], 92 | '--no-interaction' => true, 93 | ]) 94 | ->expectsOutput('Factory blueprint created!') 95 | ->run(); 96 | 97 | $this->assertFileExists(database_path('factories/HabitFactory.php')); 98 | } 99 | 100 | /** @test */ 101 | public function it_can_create_prefilled_factories_for_all_models() 102 | { 103 | $this->artisan('generate:factory', [ 104 | '--no-interaction' => true, 105 | '--path' => __DIR__.'/Fixtures/Models', 106 | '--include-nullable' => true, 107 | ]) 108 | ->expectsOutput('3 factories created') 109 | ->run(); 110 | 111 | $this->assertFileExists(database_path('factories/CarFactory.php')); 112 | $this->assertFileExists(database_path('factories/HabitFactory.php')); 113 | $this->assertFileExists(database_path('factories/UserFactory.php')); 114 | } 115 | 116 | /** @test */ 117 | public function it_can_create_prefilled_factories_for_defined_models_only_with_including_namespace() 118 | { 119 | $this->artisan('generate:factory', [ 120 | 'models' => [ 121 | '\Tests\Fixtures\Models\Car', 122 | '\Tests\Fixtures\Models\Habit', 123 | ], 124 | '--no-interaction' => true, 125 | '--include-nullable' => true, 126 | ]) 127 | ->expectsOutput('2 factories created') 128 | ->run(); 129 | 130 | $this->assertFileExists(database_path('factories/CarFactory.php')); 131 | $this->assertFileExists(database_path('factories/HabitFactory.php')); 132 | } 133 | 134 | /** @test */ 135 | public function it_can_include_nullable_properties_in_factories() 136 | { 137 | $this->artisan('generate:factory', [ 138 | 'models' => [Car::class], 139 | '--no-interaction' => true, 140 | '--include-nullable' => true, 141 | ]) 142 | ->expectsOutput('Factory created!') 143 | ->run(); 144 | 145 | $this->assertFileExists(database_path('factories/CarFactory.php')); 146 | 147 | $this->assertTrue(Str::contains( 148 | File::get(database_path('factories/CarFactory.php')), 149 | "'factory_year' => \$faker->randomNumber," 150 | )); 151 | } 152 | 153 | /** @test */ 154 | public function it_does_not_include_the_models_created_at_updated_at_or_deleted_at_timestamps_even_nullable_values_are_requested() 155 | { 156 | $this->artisan('generate:factory', [ 157 | 'models' => [Car::class], 158 | '--no-interaction' => true, 159 | '--include-nullable' => true, 160 | ]) 161 | ->expectsOutput('Factory blueprint created!') 162 | ->run(); 163 | 164 | $this->assertFileExists(database_path('factories/CarFactory.php')); 165 | $this->assertFalse(Str::contains( 166 | File::get(database_path('factories/CarFactory.php')), 167 | "'created_at' => \$faker," 168 | )); 169 | $this->assertFalse(Str::contains( 170 | File::get(database_path('factories/CarFactory.php')), 171 | "'updated_at' => \$faker," 172 | )); 173 | $this->assertFalse(Str::contains( 174 | File::get(database_path('factories/CarFactory.php')), 175 | "'deleted_at' => \$faker," 176 | )); 177 | } 178 | 179 | /** @test */ 180 | public function it_identifies_belongs_to_relations_through_relation_methods() 181 | { 182 | $this->artisan('generate:factory', [ 183 | 'models' => [Car::class], 184 | '--no-interaction' => true, 185 | '--include-nullable' => true, 186 | ]) 187 | ->expectsOutput('Factory blueprint created!') 188 | ->run(); 189 | 190 | $this->assertFileExists($path = database_path('factories/CarFactory.php')); 191 | $this->assertTrue(Str::contains( 192 | File::get($path), 193 | "'previous_owner_id' => factory(".User::class.'::class)->lazy(),' 194 | )); 195 | } 196 | 197 | /** @test */ 198 | public function it_prints_an_error_if_no_database_info_could_be_found() 199 | { 200 | $this->artisan('generate:factory', [ 201 | 'models' => [Book::class], 202 | '--no-interaction' => true, 203 | ]) 204 | ->expectsOutput('We could not find any data for your factory. Did you `php artisan migrate` already?') 205 | ->run(); 206 | } 207 | 208 | /** @test */ 209 | public function it_returns_a_no_files_found_error_if_no_files_were_found_in_the_given_directory() 210 | { 211 | $this->artisan('generate:factory', [ 212 | '--no-interaction' => true, 213 | '--path' => $directory = __DIR__.'/Fixtures/NonExistent', 214 | '--include-nullable' => true, 215 | ]) 216 | ->expectsOutput("No files in [$directory] were found!") 217 | ->assertExitCode(1) 218 | ->run(); 219 | } 220 | 221 | /** 222 | * Define database migrations. 223 | * 224 | * @return void 225 | */ 226 | protected function defineDatabaseMigrations() 227 | { 228 | $this->loadMigrationsFrom(realpath(__DIR__.'/migrations')); 229 | } 230 | 231 | protected function setUp(): void 232 | { 233 | parent::setUp(); 234 | 235 | $this->beforeApplicationDestroyed(function () { 236 | File::cleanDirectory(app_path()); 237 | File::cleanDirectory(database_path('factories')); 238 | }); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | typeGuesser = resolve(TypeGuesser::class); 19 | } 20 | 21 | /** @test */ 22 | public function it_can_guess_boolean_values_by_type() 23 | { 24 | $this->assertEquals('boolean()', $this->typeGuesser->guess('is_verified', 'boolean')); 25 | } 26 | 27 | /** @test */ 28 | public function it_can_guess_random_integer_values_by_type() 29 | { 30 | $this->assertEquals('randomNumber()', $this->typeGuesser->guess('integer', 'integer')); 31 | $this->assertEquals('randomNumber(10)', $this->typeGuesser->guess('integer', 'integer', 10)); 32 | 33 | $this->assertEquals('randomNumber()', $this->typeGuesser->guess('big_int', 'bigint')); 34 | $this->assertEquals('randomNumber(10)', $this->typeGuesser->guess('big_int', 'bigint', 10)); 35 | 36 | $this->assertEquals('randomNumber()', $this->typeGuesser->guess('small_int', 'smallint')); 37 | $this->assertEquals('randomNumber(10)', $this->typeGuesser->guess('small_int', 'smallint', 10)); 38 | } 39 | 40 | /** @test */ 41 | public function it_can_guess_random_decimal_values_by_type() 42 | { 43 | $this->assertEquals('randomFloat()', $this->typeGuesser->guess('decimal_value', 'decimal')); 44 | $this->assertEquals('randomFloat(10)', $this->typeGuesser->guess('decimal_value', 'decimal', 10)); 45 | } 46 | 47 | /** @test */ 48 | public function it_can_guess_random_float_values_by_type() 49 | { 50 | $this->assertEquals('randomFloat()', $this->typeGuesser->guess('float_value', 'float')); 51 | $this->assertEquals('randomFloat(10)', $this->typeGuesser->guess('float_value', 'float', 10)); 52 | } 53 | 54 | /** @test */ 55 | public function it_can_guess_date_time_values_by_type() 56 | { 57 | $this->assertEquals('dateTime()', $this->typeGuesser->guess('done_at', 'datetime')); 58 | $this->assertEquals('date()', $this->typeGuesser->guess('birthdate', $this->getType(Types::DATE_IMMUTABLE))); 59 | $this->assertEquals('time()', $this->typeGuesser->guess('closing_at', $this->getType(Types::TIME_IMMUTABLE))); 60 | } 61 | 62 | /** @test */ 63 | public function it_can_guess_text_values_by_type() 64 | { 65 | $this->assertEquals('text()', $this->typeGuesser->guess('body', 'text')); 66 | } 67 | 68 | /** @test */ 69 | public function it_can_guess_name_values() 70 | { 71 | $this->assertEquals('name()', $this->typeGuesser->guess('name', 'no-op')); 72 | } 73 | 74 | /** @test */ 75 | public function it_can_guess_first_name_values() 76 | { 77 | $this->assertEquals('firstName()', $this->typeGuesser->guess('first_name', 'no-op')); 78 | $this->assertEquals('firstName()', $this->typeGuesser->guess('firstname', 'no-op')); 79 | } 80 | 81 | /** @test */ 82 | public function it_can_guess_last_name_values() 83 | { 84 | $this->assertEquals('lastName()', $this->typeGuesser->guess('last_name', 'no-op')); 85 | $this->assertEquals('lastName()', $this->typeGuesser->guess('lastname', 'no-op')); 86 | } 87 | 88 | /** @test */ 89 | public function it_can_guess_user_name_values() 90 | { 91 | $this->assertEquals('userName()', $this->typeGuesser->guess('username', 'no-op')); 92 | $this->assertEquals('userName()', $this->typeGuesser->guess('user_name', 'no-op')); 93 | $this->assertEquals('userName()', $this->typeGuesser->guess('login', 'no-op')); 94 | } 95 | 96 | /** @test */ 97 | public function it_can_guess_email_values() 98 | { 99 | $this->assertEquals('email()', $this->typeGuesser->guess('email', 'no-op')); 100 | $this->assertEquals('email()', $this->typeGuesser->guess('emailaddress', 'no-op')); 101 | $this->assertEquals('email()', $this->typeGuesser->guess('email_address', 'no-op')); 102 | } 103 | 104 | /** @test */ 105 | public function it_can_guess_phone_number_values() 106 | { 107 | $this->assertEquals('phoneNumber()', $this->typeGuesser->guess('phonenumber', 'no-op')); 108 | $this->assertEquals('phoneNumber()', $this->typeGuesser->guess('phone_number', 'no-op')); 109 | $this->assertEquals('phoneNumber()', $this->typeGuesser->guess('phone', 'no-op')); 110 | $this->assertEquals('phoneNumber()', $this->typeGuesser->guess('telephone', 'no-op')); 111 | $this->assertEquals('phoneNumber()', $this->typeGuesser->guess('telnumber', 'no-op')); 112 | } 113 | 114 | /** @test */ 115 | public function it_can_guess_address_values() 116 | { 117 | $this->assertEquals('address()', $this->typeGuesser->guess('address', 'no-op')); 118 | } 119 | 120 | /** @test */ 121 | public function it_can_guess_city_values() 122 | { 123 | $this->assertEquals('city()', $this->typeGuesser->guess('city', 'no-op')); 124 | $this->assertEquals('city()', $this->typeGuesser->guess('town', 'no-op')); 125 | } 126 | 127 | /** @test */ 128 | public function it_can_guess_street_address_values() 129 | { 130 | $this->assertEquals('streetAddress()', $this->typeGuesser->guess('street_address', 'no-op')); 131 | $this->assertEquals('streetAddress()', $this->typeGuesser->guess('streetAddress', 'no-op')); 132 | } 133 | 134 | /** @test */ 135 | public function it_can_guess_postcode_values() 136 | { 137 | $this->assertEquals('postcode()', $this->typeGuesser->guess('postcode', 'no-op')); 138 | $this->assertEquals('postcode()', $this->typeGuesser->guess('zipcode', 'no-op')); 139 | $this->assertEquals('postcode()', $this->typeGuesser->guess('postalcode', 'no-op')); 140 | $this->assertEquals('postcode()', $this->typeGuesser->guess('postal_code', 'no-op')); 141 | $this->assertEquals('postcode()', $this->typeGuesser->guess('postalCode', 'no-op')); 142 | } 143 | 144 | /** @test */ 145 | public function it_can_guess_state_values() 146 | { 147 | $this->assertEquals('state()', $this->typeGuesser->guess('state', 'no-op')); 148 | $this->assertEquals('state()', $this->typeGuesser->guess('province', 'no-op')); 149 | $this->assertEquals('state()', $this->typeGuesser->guess('county', 'no-op')); 150 | } 151 | 152 | /** @test */ 153 | public function it_can_guess_country_values() 154 | { 155 | $this->assertEquals('countryCode()', $this->typeGuesser->guess('country', 'no-op', 2)); 156 | $this->assertEquals('countryISOAlpha3()', $this->typeGuesser->guess('country', 'no-op', 3)); 157 | $this->assertEquals('country()', $this->typeGuesser->guess('country', 'no-op')); 158 | } 159 | 160 | /** @test */ 161 | public function it_can_guess_locale_values() 162 | { 163 | $this->assertEquals('locale()', $this->typeGuesser->guess('country', 'no-op', 5)); 164 | $this->assertEquals('locale()', $this->typeGuesser->guess('country', 'no-op', 6)); 165 | $this->assertEquals('locale()', $this->typeGuesser->guess('locale', 'no-op')); 166 | } 167 | 168 | /** @test */ 169 | public function it_can_guess_currency_code_values() 170 | { 171 | $this->assertEquals('currencyCode()', $this->typeGuesser->guess('currency', 'no-op')); 172 | $this->assertEquals('currencyCode()', $this->typeGuesser->guess('currencycode', 'no-op')); 173 | $this->assertEquals('currencyCode()', $this->typeGuesser->guess('currency_code', 'no-op')); 174 | } 175 | 176 | /** @test */ 177 | public function it_can_guess_url_values() 178 | { 179 | $this->assertEquals('url()', $this->typeGuesser->guess('website', 'no-op')); 180 | $this->assertEquals('url()', $this->typeGuesser->guess('url', 'no-op')); 181 | $this->assertEquals('url()', $this->typeGuesser->guess('twitter_url', 'no-op')); 182 | $this->assertEquals('url()', $this->typeGuesser->guess('endpoint_url', 'no-op')); 183 | } 184 | 185 | /** @test */ 186 | public function it_can_guess_image_url_values() 187 | { 188 | $this->assertEquals('imageUrl()', $this->typeGuesser->guess('image_url', 'no-op')); 189 | } 190 | 191 | /** @test */ 192 | public function it_can_guess_company_values() 193 | { 194 | $this->assertEquals('company()', $this->typeGuesser->guess('company', 'no-op')); 195 | $this->assertEquals('company()', $this->typeGuesser->guess('companyname', 'no-op')); 196 | $this->assertEquals('company()', $this->typeGuesser->guess('company_name', 'no-op')); 197 | $this->assertEquals('company()', $this->typeGuesser->guess('employer', 'no-op')); 198 | } 199 | 200 | /** @test */ 201 | public function it_can_guess_title_values() 202 | { 203 | $this->assertEquals('title()', $this->typeGuesser->guess('title', 'no-op', 10)); 204 | $this->assertEquals('title()', $this->typeGuesser->guess('title', 'no-op')); 205 | } 206 | 207 | /** @test */ 208 | public function it_can_guess_sentence_values() 209 | { 210 | $this->assertEquals('sentence()', $this->typeGuesser->guess('title', 'no-op', 15)); 211 | } 212 | 213 | /** @test */ 214 | public function it_can_guess_password_values() 215 | { 216 | $this->assertEquals('password()', $this->typeGuesser->guess('password', 'no-op')); 217 | } 218 | 219 | /** @test */ 220 | public function it_can_guess_coordinates_based_on_their_names() 221 | { 222 | $this->assertEquals('latitude()', $this->typeGuesser->guess('latitude', 'no-op')); 223 | $this->assertEquals('longitude()', $this->typeGuesser->guess('longitude', 'no-op')); 224 | } 225 | 226 | /** @test */ 227 | public function it_returns_word_as_default_value() 228 | { 229 | $this->assertEquals('word()', $this->typeGuesser->guess('not_guessable', 'no-op')); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /tests/migrations/0000_00_00_000001_create_users_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->string('name'); 17 | $table->string('email')->unique()->nullable(); 18 | $table->string('password'); 19 | 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | */ 27 | public function down() 28 | { 29 | Schema::dropIfExists('users'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/migrations/0000_00_00_000002_create_habits_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->integer('user_id')->unsigned(); 17 | $table->string('name'); 18 | $table->timestamps(); 19 | 20 | $table->foreign('user_id') 21 | ->references('id') 22 | ->on('users'); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('habits'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/migrations/0000_00_00_000003_create_cars_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->integer('owner_id')->unsigned(); 17 | $table->unsignedInteger('previous_owner_id'); 18 | $table->string('brand'); 19 | $table->integer('factory_year')->nullable(); 20 | $table->timestamps(); 21 | 22 | $table->foreign('owner_id') 23 | ->references('id') 24 | ->on('users'); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | */ 31 | public function down() 32 | { 33 | Schema::dropIfExists('cars'); 34 | } 35 | } 36 | --------------------------------------------------------------------------------