Laravel単体テストでDBにアクセスしないでユニットテストをコーディングしたいと思ってます。
ドリンクのECサイト的なWEBアプリを作ってます。
飲み物購入時に行われるDBアクセス(ストックを確認する、未成年のチェック、残金を減らしてストック減らすなど)を行うコントローラのユニットテストを書くことになりました。
書くことになったのですが、、、
後述のDrinkService.phpはDBへの永続化を責務とするDrinkRepository.php(DBモデルクラス)に直接依存しているため、、、
DrinkServiceクラス, DrinkRepositoryクラスをリファクタリングし
DBに依存しないユニットテストを書く必要がある(テストごとにDBの状態を適切にセットする)とのことなのですが、、、
このような設計センス(?)を今まで意識してコーディングする機会がなかったので、いきなり言われて戸惑ってます。
どのようなコーディングが「DBに直接依存している」ことを指すのか、
また直接依存してたらなぜよろしくないのか?リファクタリングしたらどのようないい事があるのかなど教えていただけたらと思います。(新たなクラス, 抽象クラス, インターフェースを実装はあり)
よろしくお願いします。
##追記
DBに直接依存しているとはdbにアクセスが行われることとご回答をいただきました。
そこで今度は、DBにアクセスさせないで上記クラスをリファクタリングしたり、別クラスを追加したりして実現できるものなのか?とまた疑問に感じました?(予め擬似的なDBで使われる値をセットした別クラスを用意しておくとか?)
##ソース(計3ファイル)
controler
1<?php 2 3declare(strict_types=1); 4 5namespace App\Services\Drink; 6 7use App\Exceptions\Drink\Buy\GuiltyException; 8use App\Exceptions\Drink\Buy\NoStockException; 9use App\Exceptions\Drink\Buy\NotEnoughMoneyException; 10use App\Exceptions\Drink\NotFoundException; 11use App\Models\Drink; 12use App\Models\User; 13use App\Repositories\DrinkRepository; 14use App\Repositories\UserRepository; 15 16/** 17 * Class DrinkService 18 * @package App\Services\Drink 19 */ 20final class DrinkService 21{ 22 private DrinkRepository $drinkRepository; 23 private UserRepository $userRepository; 24 25 public function __construct() 26 { 27 $this->drinkRepository = new DrinkRepository(); 28 $this->userRepository = new UserRepository(); 29 } 30 31 /** 32 * ドリンク一覧を取得する 33 * @return iterable 34 */ 35 public function list(): iterable 36 { 37 return $this->drinkRepository->all(); 38 } 39 40 /** 41 * ドリンクを購入する 42 * @param User $user 購入するユーザー 43 * @param int $drinkId ドリンクID 44 * @return DrinkBoughtResult 購入後のドリンクとユーザーの状態 45 * @throws GuiltyException 46 * @throws NoStockException 47 * @throws NotEnoughMoneyException 48 * @throws NotFoundException|\App\Exceptions\User\NotFoundException 49 */ 50 public function buy(User $user, int $drinkId): DrinkBoughtResult 51 { 52 $drink = $this->drinkRepository->find($drinkId); 53 54 if (is_null($drink)) { 55 throw new NotFoundException(); 56 } 57 58 if ($drink->stock <= 0) { 59 throw new NoStockException(); 60 } 61 62 if ($user->wallet < $drink->price) { 63 throw new NotEnoughMoneyException(); 64 } 65 66 if ($drink->isAlcohol && $user->age < 20) { 67 throw new GuiltyException(); 68 } 69 70 $newDrink = new Drink( 71 $drink->id, 72 $drink->name, 73 $drink->price, 74 $drink->stock - 1, 75 $drink->isAlcohol, 76 ); 77 78 $newUser = new User( 79 $user->id, 80 $user->name, 81 $user->age, 82 $user->wallet - $drink->price, 83 ); 84 85 $this->drinkRepository->save($newDrink); 86 $this->userRepository->save($newUser); 87 88 return new DrinkBoughtResult($newUser, $newDrink); 89 } 90} 91
Model
1<?php 2 3declare(strict_types=1); 4 5namespace App\Repositories; 6 7use App\Exceptions\Drink\NotFoundException; 8use App\Models\Drink; 9use Illuminate\Database\ConnectionInterface; 10 11/** 12 * Class DrinkRepository 13 * @package App\Repositories 14 */ 15final class DrinkRepository 16{ 17 /** 18 * テーブル名 19 */ 20 private const TABLE = 'drinks'; 21 22 /** 23 * @var ConnectionInterface 24 */ 25 private ConnectionInterface $connection; 26 27 public function __construct() 28 { 29 $this->connection = app()->get(ConnectionInterface::class); 30 } 31 32 /** 33 * ドリンクを全件取得する 34 * @return iterable 35 */ 36 public function all(): iterable 37 { 38 return $this->connection->table(self::TABLE) 39 ->get() 40 ->map(fn(\stdClass $d) => $this->createDrinkModel($d)); 41 } 42 43 /** 44 * ドリンクをIDにより取得する 45 * @param int $id 46 * @return Drink|null 47 */ 48 public function find(int $id): ?Drink 49 { 50 $d = $this->connection->table(self::TABLE) 51 ->find($id); 52 53 return is_null($d) ? null : $this->createDrinkModel($d); 54 } 55 56 /** 57 * ドリンクを保存する 58 * @param Drink $drink 59 * @throws NotFoundException 60 */ 61 public function save(Drink $drink): void 62 { 63 $query = $this->connection->table(self::TABLE) 64 ->where('id', $drink->id); 65 if (!$query->exists()) { 66 throw new NotFoundException('ドリンクの新規作成はできません。'); 67 } 68 69 $query->update( 70 [ 71 'name' => $drink->name, 72 'price' => $drink->price, 73 'stock' => $drink->stock, 74 'is_alcohol' => $drink->isAlcohol, 75 ] 76 ); 77 } 78 79 private function createDrinkModel(\stdClass $d): Drink 80 { 81 return new Drink( 82 $d->id, 83 $d->name, 84 $d->price, 85 $d->stock, 86 (boolean)$d->is_alcohol, 87 ); 88 } 89} 90
TestCode
1<?php 2 3declare(strict_types=1); 4 5namespace Tests\Unit\Services\Drink; 6 7use App\Exceptions\Drink\Buy\NoStockException; 8use App\Models\User; 9use App\Services\Drink\DrinkService; 10use Tests\TestCase; 11 12class DrinkServiceBuyTest extends TestCase 13{ 14 // TODO: DrinkService::buy()の分岐をすべてテストすること 15 16 /** 17 * FIXME: 在庫数がDBに依存しており, DBの状態によってこのテストは失敗するので困っています。 18 * @testdox 在庫が0以下の場合, NoStockExceptionをスローすること 19 * @throws 20 */ 21 public function test_buy_should_throws_no_stock_exception_if_stock_0_or_less(): void 22 { 23 $this->expectException(NoStockException::class); 24 25 $service = new DrinkService(); 26 $user = new User(1, 'テストユーザー', 20, 100); 27 $service->buy($user, 1); 28 } 29} 30
##DB定義
create table drinks ( id bigint unsigned auto_increment comment 'ID' primary key, name varchar(255) not null comment '商品名', price int unsigned not null comment '価格', stock int unsigned not null comment '在庫数', is_alcohol tinyint(1) not null comment 'アルコール飲料であるか' ) collate=utf8mb4_unicode_ci; create table users ( id bigint unsigned auto_increment comment 'ID' primary key, name varchar(255) not null comment '名前', age tinyint unsigned not null comment '年齢', wallet int unsigned not null comment '所持金' )
回答3件
あなたの回答
tips
プレビュー
バッドをするには、ログインかつ
こちらの条件を満たす必要があります。