狛ログ

2022年4月28日木曜日

Angular ユニットテスト エラー( NullInjectorError: No provider for Router )の対応。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

突然ですが、Angularでユニットテストしてますか?
CLIでコンポーネントを作ると、「xxxxxx.component.spec.ts」が作成されますが、
「そのまま何も変えない(ユニットテスト使わない) or 邪魔だから消す」と、なっているかもしれないですね・・・・
まあ、Angularのユニットテスト、割と難しいので、気持ちは分かります。

という事で、Angularでユニットテストを書いていく上で、良く出るエラーの対処方法を記載していこうと思います。
今回はDI関連のエラーです。

NullInjectorError: R3InjectorError(DynamicTestModule)[Router -> Router]: 
  NullInjectorError: No provider for Router!
みたいなエラーが出てしまった場合の対応ですね。
メッセージそのままですが、Router絡みのエラーであることが分かります。

Angularでは、DI可能なクラスであれば、コンポーネントのコンストラクタに定義する事で、DIして使用可能になります。
[hoge.component.ts]
import { Router } from '@angular/router';
// (中略)
  constructor(private router: Router) {}
// (中略)
  onClickBack(): void {
  	// 「戻る」ボタンを押したら、「Top画面」に遷移する
    this.router.navigateByUrl('/hoge/top');
  }

・・・つまりテストクラス側でもDIをする必要があるって事ですね
[hoge.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { HogeComponent } from './hoge.component';

describe('HogeComponent', () => {
  let component: HogeComponent;
  let fixture: ComponentFixture<HogeComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HogeComponent],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(HogeComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
って感じになります。
デフォルト(CLIによって作成された状態)から、
import { RouterTestingModule } from '@angular/router/testing';
と、
      imports: [
        RouterTestingModule,
      ],
を追加しています。

先ほどのエラー(NullInjectorError)を無くすだけであれば、実は上記でOKです。

ただ、当然テストケースは作らないといけないので、ついでにやってみましょう
[hoge.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
// Routerをimport
import { Router } from '@angular/router';
import { HogeComponent } from './hoge.component';

describe('HogeComponent', () => {
  let component: HogeComponent;
  let fixture: ComponentFixture<HogeComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let router: Router;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HogeComponent],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(HogeComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ボタンアクションテスト', () => {
    it('戻るボタン押下時  ホーム画面へ遷移する事', () => {
      // navigateByUrl処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // テスト対象のメソッドを呼び出す
      component.onClickBack();

      // navigateByUrlが、'/hoge/top'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/hoge/top');
    });
  });
});

ポイントとしては、DIしたクラスをテスト側で使用する為には、
    router = TestBed.inject(Router);
が必要、というところです。
実際のテストの部分は、プログラム内のコメントを参照して頂ければと思いますが、流れ的に、

① spyOn で、navigateByUrl のモックを作成
② コンポーネント側の onClickBack を呼び出す
③ expect の toHaveBeenCalledWith で、指定の引数で navigateByUrl が呼ばれたかを判定

となっています。

ちょっと長くなってしまったので、その他のDI関連のテストエラーは別途記事にしようと思います。

2022年4月22日金曜日

【C#】モーダル内に非同期でデータを表示させたかった話【ajax】

おはようございます。こんにちは。こんばんは。
オフィス狛 開発担当のnagoです。

今回は、最近初めて使用したC#(ASP.NET Core MVC)で、
なるほどなぁと思ったモーダル実装についてまとめたいと思います。
DBはPostgreSQL、ビューにはBootstrapを使用しました。

<やりたかったこと>
親画面からボタンクリック

親画面とは別のcshtmlファイルに記述したモーダルを開きつつ、
DBから持ってきたデータをモーダル内に表示させる

<できたもの>
コンサートなどのチケット購入サイト風サンプルを作成してみました。

・Topページ


↓詳細ボタンを押下したら

こんなかんじで表示。
・モーダル

データはこんなかんじ。

処理全体の流れとしては、
①親画面にモーダルのcshtmlを入れ込むためのdivを置いておく
②ボタンが押されたら、ajaxで非同期でコントローラにデータ取得用パラメータを渡す
③コントローラでデータ取得
④取得したデータを成形してビューモデルにセット
⑤コントローラからPertialViewでjsにcshtmlとビューモデルを返却する
⑥モーダル表示
というかんじで作ってみました。

<ソース>
・TopController.cs
using Microsoft.AspNetCore.Mvc;
using DotNetTest.Models;

namespace DotNetTest.Controllers
{
    public class TopController : Controller
    {
        private readonly DotNetTestContext _context;

        public TopController(DotNetTestContext context){
            this._context = context;
        }

        [HttpGet]
        public IActionResult Top() {
            return View(this._context.performance);
        }
    }
}
・ModalController.cs
using Microsoft.AspNetCore.Mvc;
using DotNetTest.Models;
using System.Linq;

namespace DotNetTest.Controllers
{
    public class ModalController : Controller
    {
        private readonly DotNetTestContext _context;

        public ModalController(DotNetTestContext context){
            this._context = context;
        }

        [HttpPost]
        public IActionResult ShowModal(ModalViewModel viewModel) {
            try
            {
                if ( viewModel.id != string.Empty && _context.performance != null) {
                    if( Int32.TryParse( viewModel.id, out int intId )) {
                        // レコード取得
                        var performance = _context.performance.SingleOrDefault(p => p.id == intId);
                        if (performance != null) {
                            viewModel.name = performance.name;
                            viewModel.date = performance.date.ToString("yyyy年MM月dd日 HH:mm");
                            viewModel.place = performance.place;
                            viewModel.description = performance.description;
                            viewModel.errorMessage = string.Empty;
                            // ビューとビューモデルを返却
                            return PartialView("Modal", viewModel);
                        }
                    }
                }
                // エラーの場合はエラーメッセージ入りビューモデルを返却
                return Json(viewModel);
            }
            catch (System.Exception)
            {
                throw;
            }
        }
    }
}
・DotNetTestContext.cs
using Microsoft.EntityFrameworkCore;
using DotNetTest.Models;

namespace DotNetTest.Models
{
    public class DotNetTestContext: DbContext
    {
        public DotNetTestContext(DbContextOptions options) : base(options)
        {           
        }
        public DbSet performance { get; set; } = null!;
    }
}
・Performance.cs
namespace DotNetTest.Models
{
    public class performance
    {
        public int id { get; set; }
        public string name { get; set; } = string.Empty;
        public DateTime date { get; set; }
        public string place { get; set; } = string.Empty;
        public string description { get; set; } = string.Empty;
    }
}
・ModalViewModel.cs
using Microsoft.AspNetCore.Mvc;

namespace DotNetTest.Models
{
    [BindProperties]
    public class ModalViewModel
    {
        public string id { get; set; } = string.Empty;
        public string name { get; set; } = string.Empty;
        public string date { get; set; } = string.Empty;
        public string place { get; set; } = string.Empty;
        public string description { get; set; } = string.Empty;
        public string errorMessage { get; set; } = "エラーが発生しました";
    }
}
・Top.cshtml
@model IEnumerable<DotNetTest.Models.performance>
@{
  ViewData["Title"] = "Top";
}
<!doctype html>
<html lang="ja" >
<head>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
  <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" >
</head>
<body>
  <table>
    <tr>
      <td colspan="2">
        <p class="error_message" id="error_message"></p>
      </td>
    </tr>
    @foreach(var item in Model)
    {
      <tr>
        <td>
          <p>・ @item.name @item.date.ToString("yyyy年M月d日 HH:mm")〜</p>
        </td>
        <td class="button_td">
          <button type="button" class="btn btn-primary" data-toggle="modal" value="@item.id" onclick="openModal(this.value);">
            詳細
          </button>
        </td>
      </tr>
    }
  </table>
  <div id="modal_base"></div>
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
  <script src="~/js/site.js" asp-append-version="true"></script>
</body>
</html>
・Modal.cshtml
@using DotNetTest.Models
@model ModalViewModel
<!-- Modal -->
<div class="modal fade" id="modal" tabindex="-1" aria-labelledby="modalLabel" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">@Model.name</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
          <span aria-hidden="true">&times;</span>
        </button>
      </div>
      <div class="modal-body">
        <p>
          公演日時:@Model.date 〜<br>
          会場:@Model.place<br>
          説明:@Model.description
        </p>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-primary">
          予約
        </button>
      </div>
    </div>
  </div>
</div>
・site.js
function openModal(id) {
    $('#error_message').val('');
    // コントローラ名/メソッド名
    const url = new URL(window.location.href) + 'Modal/ShowModal';
    // パラメータ成形
    let form = new FormData();
    form.append('id', id);
    $.ajax({
        url: url,
        type: 'POST',
        processData: false,
        contentType: false,
        data: form
    }).done(function(data){
        const data_stringify = JSON.stringify(data);
        const data_json = JSON.parse(data_stringify);
        if(data_json.errorMessage != null) {
            $('#error_message').append(data_json.errorMessage);
        } else {
            // 親画面に置いておいたdivに、コントローラから受け取ったモーダルのcshtmlをセットする
            $('#modal_base').html(data);
            // モーダル表示
            $('#modal_base').find('.modal').modal('show');
        }
    });
}

親画面と子画面(モーダル)のcshtmlを別ファイルで管理したかったのと、
jsファイルも別ファイルで管理したかったということがあり、苦戦したのですが、
これで思い通りの動きに出来た時はすごく嬉しかったです。
もし同じようなことで躓いた方がいらしたら、参考になれば幸いです。

参考URL:
https://docs.microsoft.com/ja-jp/dotnet/api/system.web.mvc.controller.partialview?view=aspnet-mvc-5.2
, , ,