(WIP) Angular Testing メモ

これは何か

Angular の公式ドキュメント(Angular - Testing)とテストに関する前提知識(用語、Jasmine使い方など)をまとめようとしたもの。

前提知識

Jasmine

spyOn(オブジェクト, 'メソッド名')

// 本来の実装も実行
spyOn(obj, 'method').and.callThrough();

// 戻り値を変更
spyOn(obj, 'method').and.returnValue('stub');

// 処理を任意の関数に置き換える
spyOn(obj, 'method').and.callFake(...)

calls.count();
calls.first();
calls.mostRecent();

// 裸のスパイ関数
var spyFunction = jasmine.createSpy();

// 裸のスパイオブジェクト
var spyObj = jasmine.createSpyObj('spyName', ['hoge', 'fuga', 'piyo']);

用語

  • テストダブル - Wikipedia
  • テストダブル (Test Double) とは、ソフトウェアテストにおいて、テスト対象が依存しているコンポーネントを置き換える代用品のこと。ダブルは代役、影武者を意味する

    • テストスタブ (テスト対象に「間接的な入力」を提供するために使う。)
    • テストスパイ (テスト対象からの「間接的な出力」を検証するために使う。出力を記録しておくことで、テストコードの実行後に、値を取り出して検証できる。)
    • モックオブジェクト (テスト対象からの「間接的な出力」を検証するために使う。テストコードの実行前に、あらかじめ期待する 結果を設定しておく。検証はオブジェクト内部で行われる。)
    • フェイクオブジェクト (実際のオブジェクトに近い働きをするが、より単純な実装を使う。例として、実際のデータベースを置き換えるインメモリデータベースが挙げられる。)
    • ダミーオブジェクト (テスト対象のメソッドがパラメータを必要としているが、そのパラメータが利用されない場合に渡すオブジェクト。
  • スタブ - Wikipedia

    • スタブ (stub) とは、コンピュータプログラムのモジュールをテストする際、そのモジュールが呼び出す下位モジュールの代わりに用いる代用品のこと。

  • FakeとMockとStubの違い - kkamegawa’s weblog
    • Stub: Prevent the dependency from obstructing the method-under-test and torespond in a way that helps it proceed through its logical steps.

    • Mock: Allow the test code to verify that the code-under-test’s interaction with the dependency is proper, valid, and expected.

Service Tests

https://angular.io/guide/testing#service-tests

コンポーネントのテストは複雑になりがちなので、まずはサービスのテストから。

依存がない場合

service = new ValueService();

他のサービスに依存している場合

// 1. 実際のサービスをInject
masterService = new MasterService(new ValueService());

// 2. FakeサービスをInject
masterService = new MasterService(new FakeValueService());

// 3. Fake objectをInject
const fake =  { getValue: () => 'fake value' };
masterService = new MasterService(fake as ValueService);

// 4. Spy serviceをInject
const valueServiceSpy = jasmine.createSpyObj('ValueService', ['getValue']);
const stubValue = 'stub value';
valueServiceSpy.getValue.and.returnValue(stubValue);
masterService = new MasterService(valueServiceSpy);

実際のアプリケーションと同じように Angular の DI を利用する必要があるのであれば TestBed を利用。
https://angular.io/guide/testing#angular-testbed

let service: ValueService;

beforeEach(() => {
  TestBed.configureTestingModule({ providers: [ValueService] });
});

it('should use ValueService', () => {
  service = TestBed.get(ValueService);
  expect(service.getValue()).toBe('real value');
});

Testing without beforeEach()

https://angular.io/guide/testing#testing-without-beforeeach

beforeEachを必ずしも利用する必要はない

function setup() {
  const valueServiceSpy =
    jasmine.createSpyObj('ValueService', ['getValue']);
  const stubValue = 'stub value';
  const masterService = new MasterService(valueServiceSpy);

  valueServiceSpy.getValue.and.returnValue(stubValue);
  return { masterService, stubValue, valueServiceSpy };
}

it('#getValue should return stubbed value from a spy', () => {
  const { masterService, stubValue, valueServiceSpy } = setup();
  // ...
});

Testing HTTP services

https://angular.io/guide/testing#testing-http-services

HttpClientのSpyをつくる

beforeEach(() => {
  httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
  TestBed.configureTestingModule({
    providers: [
      ApiService,
      { provide: HttpClient, useValue: httpClientSpy },
    ],
  });
});
// or
beforeEach(() => {
  httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
  service = new ApiService(<any>httpClientSpy);
});
// 正常系
httpClientSpy.get.and.returnValue(asyncData(json));
// エラー系
httpClientSpy.get.and.returnValue(asyncError(errorResponse));

Component Test Basics

https://angular.io/guide/testing#component-test-basics

大抵の場合、DOM関与なしでコンポーネントクラスだけをテストした方が、動作の多くを簡単に検証できる。実際、単純なコンポーネントならnew FooComponent()のようにインスタンスを生成するだけでテストができる。

// component
export class DashboardHeroComponent {
  @Input() hero: Hero;
  @Output() selected = new EventEmitter<Hero>();
  click() { this.selected.emit(this.hero); }
}

// testing
it('raises the selected event when clicked', () => {
  const comp = new DashboardHeroComponent();
  const hero: Hero = { id: 42, name: 'Test' };
  comp.hero = hero;

  comp.selected.subscribe(selectedHero => expect(selectedHero).toBe(hero));
  comp.click();
});

依存関係がある場合はTestBedを利用する。

beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

// ...

// 手動で ngOnInit 
it('should welcome logged in user after Angular calls ngOnInit', () => {
  comp.ngOnInit();
  expect(comp.welcome).toContain(userService.user.name);
});

Component DOM testing

https://angular.io/guide/testing#component-dom-testing

DOMのテストも必要な場合。コンポーネントを生成してテストする必要がある。

コンポーネントを生成するテストコード

describe('BannerComponent (minimal)', () => {
  it('should create', () => {
    TestBed.configureTestingModule({
      declarations: [ BannerComponent ]
    });
    const fixture = TestBed.createComponent(BannerComponent);
    const component = fixture.componentInstance;
    expect(component).toBeDefined();
  });
});

nativeElement

https://angular.io/guide/testing#nativeelement

DOMのAPIを利用したい場合。

const bannerElement: HTMLElement = fixture.nativeElement;
const p = bannerElement.querySelector('p');

DebugElement

https://angular.io/guide/testing#debugelement

The DebugElement offers query methods that work for all supported platforms.

Component Test Scenarios

https://angular.io/guide/testing#component-test-scenarios

Component binding

https://angular.io/guide/testing#component-binding

Change an input value with dispatchEvent()

input elementの場合はdispatchEventも実行させる。

const nameInput: HTMLInputElement = hostElement.querySelector('input');

// ...

// simulate user entering new name into the input box
nameInput.value = inputName;

// dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(newEvent('input'));

// Tell Angular to update the display binding through the title pipe
fixture.detectChanges();

Component with external files

ng testなら不要

Component with a dependency

https://angular.io/guide/testing#component-with-a-dependency

依存関係があるコンポーネント

// providers: [ UserService ] // NO! Don't provide the real service!
// Provide a test-double instead

Provide service test doubles

https://angular.io/guide/testing#provide-service-test-doubles

A component-under-test doesn't have to be injected with real services. In fact, it is usually better if they are test doubles (stubs, fakes, spies, or mocks).

let userServiceStub: Partial<UserService>;

userServiceStub = {
  isLoggedIn: true,
  user: { name: 'Test User'}
};
  • Get injected services
// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);

// UserService from the root injector
userService = TestBed.get(UserService);

Component with async service

https://angular.io/guide/testing#component-with-async-service

Testing with a spy

const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = twainService.getQuote.and.returnValue( of(testQuote) );

Synchronous tests

https://angular.io/guide/testing#synchronous-tests

Async test with fakeAsync()

https://angular.io/guide/testing#async-test-with-fakeasync

The tick() function

Calling tick() simulates the passage of time until all pending asynchronous activities finish. In this case, it waits for the error handler's setTimeout();

Async observables

Async observable helpers

RxJS defer() returns an observable. It takes a factory function that returns either a promise or an observable. When something subscribes to defer's observable, it adds the subscriber to a new observable created with that factory.

RxJS defer() transform the Promise.resolve() into a new observable that, like HttpClient, emits once and completes. Subscribers will be unsubscribed after they receive the data value.

以下を用意

import { defer } from 'rxjs/observable/defer';

/** Create async observable that emits-once and completes
 *  after a JS engine turn */
export function asyncData<T>(data: T) {
  return defer(() => Promise.resolve(data));
}

/** Create async observable error that errors
 *  after a JS engine turn */
export function asyncError<T>(errorObject: T) {
  return defer(() => Promise.reject(errorObject));
}

More async tests

Async test with async()

https://angular.io/guide/testing#async-test-with-async

fakeではないasync

whenStable

The test must wait for the getQuote() observable to emit the next quote. Instead of calling tick(), it calls fixture.whenStable().

tickの代わりにfixture.whenStableを実行する必要がある

Jasmine done()

https://angular.io/guide/testing#jasmine-done

もちろん一般的な done も使える

But it is occasionally necessary. For example, you can't call async or fakeAsync when testing code that involves the intervalTimer() or the RxJS delay() operator.

Component marble tests

https://angular.io/guide/testing#component-marble-tests

Component with inputs and outputs

https://angular.io/guide/testing#component-with-inputs-and-outputs

@Input に対応

// ...

comp = fixture.componentInstance;

// ...

// mock the hero supplied by the parent component
expectedHero = { id: 42, name: 'Test Name' };

// simulate the parent setting the input property with that hero
comp.hero = expectedHero;

// trigger initial data binding
fixture.detectChanges();

Clicking

https://angular.io/guide/testing#clicking

Component inside a test host

https://angular.io/guide/testing#component-inside-a-test-host

  • Component with inputs and outputsと違うやり方。ホストのコンポーネントから操作する。
  • TestHostComponentを作ってその中で制御

Routing component

https://angular.io/guide/testing#routing-component

  • Routerをもっているコンポーネントのテスト
  • 「Routerのテスト」をする必要はないのでspyをつくる
const routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']);

// ...

const spy = router.navigateByUrl as jasmine.Spy;
const navArgs = spy.calls.first().args[0];

Routed components

https://angular.io/guide/testing#routed-components

  • Router Navigation の遷移先
  • ActivatedRoute をどうテストするか

paramMap returns an Observable that can emit more than one value during a test. You need the router helper function, convertToParamMap(), to create a ParamMap. Other routed components tests need a test double for ActivatedRoute.

Nested component tests

https://angular.io/guide/testing#nested-component-tests

不要なコンポーネントをどう扱うか。

Stubbing unneeded components

@Component({selector: 'app-banner', template: ''})
class BannerStubComponent {}

TestBed.configureTestingModule({
  declarations: [
    AppComponent,
    BannerStubComponent
  ]
});

NO_ERRORS_SCHEMA

tells the Angular compiler to ignore unrecognized elements and attributes.

Use both techniques together

実際には両方のテクニックを利用するはず

https://angular.io/guide/testing#components-with-routerlink

RouterLinkのスタブを作成

@Directive({
  selector: '[routerLink]',
  host: { '(click)': 'onClick()' }
})
export class RouterLinkDirectiveStub {
  @Input('routerLink') linkParams: any;
  navigatedTo: any = null;
  onClick() {
    this.navigatedTo = this.linkParams;
  }
}

By.directive and injected directives

beforeEach(() => {
  fixture.detectChanges(); // trigger initial data binding

  // find DebugElements with an attached RouterLinkStubDirective
  linkDes = fixture.debugElement
    .queryAll(By.directive(RouterLinkDirectiveStub));

  // get attached link directive instances
  // using each DebugElement's injector
  routerLinks = linkDes.map(de => de.injector.get(RouterLinkDirectiveStub));
});

Use a page object

Setup with module imports

https://angular.io/guide/testing#setup-with-module-imports

モジュールインポートについて解説

One approach is to configure the testing module from the individual pieces as in this example:

  • Import a shared module でも可能
  • 実は自身を含む Feature module を読み込んでもおk

Override component providers

https://angular.io/guide/testing#override-component-providers

どうやってコンポーネントprovidersで登録されたサービスをおきかえるか


(すでに中途半端な内容になっていますが、一旦ここまで。ここからさらに、DirectiveやPipeのテストの説明、よくある質問などがAngular - Testingには記述されています。)

6月メモ・リンク集

6月に調べたことのメモです。

Angular 関連

ngrx 関連

ngrx 実装メモ

f:id:ryotah:20180706181849p:plain

store をつくるとき、どのような順で整理して実装しているか、についてのメモです。
例えば「ID順で表示される顧客一覧画面(テキストによる検索機能と追加読み込み機能)」をつくる場合は以下のように整理します。

  1. その機能 (Feature)で「できること」のリストアップ
    • 顧客一覧表示
    • 顧客検索
    • 一覧を追加読み込み
    • 検索条件を編集
    • 別画面(別 Feature)に移動
  2. 必要な情報は何か
    • 取得した顧客情報
    • 顧客一覧のローディング状況
    • 現在のページ番号
    • 検索条件
  3. 各 state を作成
    • customer-list.reducre.ts
      • customerList: Customer[];
      • loading: boolean
      • page: number
      • query: string
  4. action になりそうなものをリストアップ
    • 検索開始(検索条件が空の場合はID順で取得)
    • 検索成功
    • 検索失敗
    • 追加読み込み開始
    • 追加読み込み成功
    • 追加読み込み失敗
    • 検索条件を更新
    • ページを離れる
  5. action と reducer の関係を考える
    • 検索開始
      • loading = true
      • (その他のデータは一旦リセット)
    • 検索成功
      • customerList = 検索結果
      • loading = false
      • page = 1
    • 検索失敗
      • loading = false
    • 追加読み込み開始
      • loading = true
    • 追加読み込み成功
      • customerList += 検索結果
      • loading = false
      • page += 1
    • 追加読み込み失敗
      • loading = false
    • ページを離れる
      • データをリセット
  6. 無駄が多そうな場合調整する
    今回は「検索」と「追加読み込み」がほぼ同じなので共通化できそう

結果

Actions
// store/actions/customer-lits.actions.ts

export enum CustomerListActionTypes {
  LoadFirstList = '[CustomerList] Load First List',
  LoadList = '[CustomerList] Load List',
  LoadListFail = '[CustomerList] Load List Fail',
  LoadListSuccess = '[CustomerList] Load List Success',
  ResetList = '[CustomerList] Reset List',
  UpdateQueryFromParamMap = '[CustomerList] Update Query From Param Map',
}

export class LoadFirstList implements Action {
  readonly type = CustomerListActionTypes.LoadFirstList;
}

export class LoadList implements Action {
  readonly type = CustomerListActionTypes.LoadList;
}

export class LoadListFail implements Action {
  readonly type = CustomerListActionTypes.LoadListFail;
}

export class LoadListSuccess implements Action {
  readonly type = CustomerListActionTypes.LoadListSuccess;
  constructor(public payload: Customer[]) {}
}

export class ResetList implements Action {
  readonly type = CustomerListActionTypes.ResetList;
}

export class UpdateQueryFromParamMap implements Action {
  readonly type = CustomerListActionTypes.UpdateQueryFromParamMap;
  constructor(public payload: ParamMap) {}
}

export type CustomerListActions =
  | LoadFirstList
  | LoadList
  | LoadListFail
  | LoadListSuccess
  | ResetList
  | UpdateQueryFromParamMap;
Reducers
// store/reducers/customer-lits.reducer.ts

export interface State {
  customerList: Customer[];
  loading: boolean;
  page: number;
  query: string;
}

const initialState: State = {
  customerList: [],
  loading: false,
  page: 0,
  query: '',
};

export function reducer(
  state = initialState,
  action: CustomerListActions
): State {
  switch (action.type) {
    case CustomerListActionTypes.LoadList:
      return { ...state, loading: true };
    case CustomerListActionTypes.LoadListFail:
      return { ...state, loading: false };
    case CustomerListActionTypes.LoadListSuccess:
      return {
        ...state,
        customerList: [...state.customerList, ...action.payload],
        loading: false,
        page: state.page + 1,
      };
    case CustomerListActionTypes.ResetList:
      return initialState;
    case CustomerListActionTypes.UpdateQueryFromParamMap:
      return { ...state, query: action.payload.get('query') || '' };
    default:
      return state;
  }
}

export const getCustomerList = state => state.customerList;
export const getLoading = state => state.loading;
export const getPage = state => state.page;
export const getQuery = state => state.query;
Effects
// store/effects/customer-lits.effects.ts

@Injectable()
export class CustomerListEffects {
  @Effect()
  loadFirst$ = this.actions$.pipe(
    ofType<LoadFirstList>(CustomerActionTypes.LoadFirstList),
    mergeMap(action => [new ResetList(), new LoadList()])
  );

  @Effect()
  load$ = this.actions$.pipe(
    ofType<LoadList>(CustomerActionTypes.LoadList),
    withLatestFrom(
      this.store.select(fromStore.getQuery),
      this.store.select(fromStore.getPage)
    ),
    switchMap(([action, query, page]) =>
      this.service.getList(query, page + 1).pipe(
        map(customerList => new LoadListSuccess(customerList)),
        catchError(error => of(new LoadListFail()))
      )
    )
  );

  constructor(
    private actions$: Actions,
    private service: CustomerListService,
    private store: Store<fromStore.State>
  ) {}
}
Container
// コンテナコンポーネント

export class CustomerListContainer {

  constructor(
    private route: ActivatedRoute,
    private router: Router,
    protected store: Store<fromStore.State>
  ) {

    route.paramMap.pipe(takeUntil(this.unsubscribe)).subscribe(queries => {
      this.store.dispatch(new UpdateQueryFromParamMap(queries));
      this.store.dispatch(new LoadFirstList());
    });

    // ...
}

CSS 関連

flex-basis」と「width」の違いは何でしょうか? 「flex-basis」は、flexコンテナの主軸の方向に相当するということです。

flex-basis」は2つとも同じ値です。つまり、十分なスペースがあれば(コンテナは600pxと余白とパディングの余裕があります)、どちらも幅が300pxになります。

しかし、コンテナが大きくなるにつれて、「.square#one」(flex-grow :2)は2倍の速さで広がります。逆に、コンテナが縮むにつれて、「.square#two」(flex-shrink :2)は2倍の速さで縮小します。

「.square#one」が広がる時に、「.square#two」の2倍のサイズには広がりません。同様に縮む時も1/2のサイズに縮むのではありません。

それぞれの値で指定した「2 1」、「1 2」はサイズではありません。広がる時と縮む時の率です。

5月メモ・リンク集

5 月に調べたことのメモです。

Angular 関連

third party lib

Once you import a library via the scripts array, you should not import it via a import statement in your TypeScript code (e.g. import * as $ from 'jquery';). If you do that you'll end up with two different copies of the library: one imported as a global library, and one imported as a module.

Testing in ngrx

// error
expect(action).toEqual({ type: LOAD_PIZZAS });
// => Expected object to be a kind of Object, but was LoadPizzas({ type: '[Products] Load Pizzas' })

// もうちょい
expect(action.type).toEqual(LOAD_PIZZAS);

// good
expect({ ...action }).toEqual({ type: LOAD_PIZZAS });

Reducers play a few key roles for us:

  • Accept old state, and an action
  • Respond to actions and compose/return new state
  • Handle changes via immutable patterns ... To go with it, my reducer - which uses an entity pattern to flatten my data structure into object keys for performance:

Version 6

Rx 関連

catchError

Rails 関連

double というメソッドを使うと、モックオブジェクトを作れます。 引数で渡す文字列は任意です。好きな文字列を渡しても構わないですし、省略することもできます。 ... RSpec では allow(モックオブジェクト).to receive(メソッド名) の形で、モックに呼び出し可能なメソッドを設定できます。

その他

type Foo = {
  name: string;
  age: number;
} & {
  [prop: string]: string;
};

4月メモ・リンク集

4 月に調べたことのメモです。

Angular 関連

Angular 6

リリース前の予習に。

モーダルの状態を ngrx で管理する

f:id:ryotah:20180506150121p:plain
  • UI state management with Redux in Angular 4 (Example) | hack.guides()
    • core => layout => openedModalName: string という state を用意
    • openedModalName を受け取るコンポーネントAppComponentに配置
    • @Input の setter でうけとりモーダルの Open/Close をハンドリングする
  • angular-modal-state-with-ngrx-wip - StackBlitz
    • ngrx で管理する方法と今まで通りの方法の比較デモ
    • (途中まで)
  • 今の考え、実装に必要なタスク
    • 複数の種類のモーダルを管理するファサードっぽいモーダルコンポーネントを用意
    • 非同期で取得したデータも渡す必要
    • Esc キーや Backdrop クリックでモーダルを閉じた時のアクション
      • => 閉じた時に ModalRef からイベントなど取得できたかも
    • モーダル内で新たなアクション、かつコンテキストに左右されるようなアクションをどうハンドリングするか
    • それらを踏まえたメリット
    • 開発中のアプリケーションでは、アクション発行 => Effect 層でモーダルオープン
      • (中途半端な気もしている)
      • ModalRef を利用したい場合

ngrx の状態管理を Router (URL) 起点にする

f:id:ryotah:20180506150105p:plain

Rails 関連

マイグレーション

  • Railsのmigrationの基本とレシピ集 - Rails Webook
    • ※upとdownは、changeメソッドの代わりに使います。 upはrake db:migrateの実行時に実行され、downはrake db:rollback時に実行されます。

    • データの投入 rake db:seedコマンド

  • rake db:drop, rake db:schema:load

Active Record クエリインターフェイス

Active Record コールバック

class Order < ApplicationRecord
  before_save :normalize_card_number, if: :paid_with_card?
end
class Order < ApplicationRecord
  before_save :normalize_card_number,
    if: Proc.new { |order| order.paid_with_card? }
end

JSのテスト 関連

spyOn を利用してインスタンスメソッドの挙動を確認する

it('calls self bar method', () => {
  spyOn(Foo.prototype, 'bar');
  new Foo();
  expect(Foo.prototype.bar).toHaveBeenCalled();
});

Docker

Circle CI

- run:
      name: Install Chrome
      command: |
        wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
        sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
        sudo apt-get update
        sudo apt-get -y install google-chrome-stable

TypeScript

http://www.typescriptlang.org/docs/handbook/advanced-types.html#mapped-types

Mapped types は便利。

type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };

// 以下は自分の利用例
export const DateFormats: {
  [key in DateFormatTypes]: DateTimeFormatOptions
}
...
export const DateFormats: {
  [key in DateFormatTypes]: DateTimeFormatOptions
}

Partial<T>も最近使うようになった。

Note that Readonly and Partial are so useful, they are included in TypeScript’s standard library along with Pick and Record:

Pick, Recordはまだ使ったことない)

その他

3月メモ・リンク集

3月に調べたことのメモです。

Angular関連

React関連

CircleCI関連

Angularで作成したアプリのCircleCI環境を2.0に移行した時のメモです。

2.0 Docs - CircleCI(オフィシャルドキュメント)

オフィシャルからは、これくらいの資料を確認。

Angular対応

It has 3 main components: a version, a list of jobs, and a list of workflows.

...

Common things that you can achieve within a job include: - Installing the tools you need to run/build/test your project - Executing bash commands - Storing or restoring items from the CircleCI cache

...

The key of the cache is generated using the checksum function, which will output a base64 encoded hash of the package.json file’s content.

...

We run the build command. Notice that we are going to use a multi-line command with each line running in the same shell, so we start the command with the pipe (|) character.

...

その他

Rails関連

Android関連

もしかしたらAndroidエンジニアになるかも、という状況があったので設計まわりについて調べてみた時の記事リンクです。Androidアーキテクチャも面白そう。

その他

Rails チュートリアル復習用メモ

以下を参考にした、Rails アプリを作成する手順の覚書です。

モデルとコントローラーの基本的な部分を知ることが目的だったので、モデルの応用的な部分や View 周りはできるだけ省いたアプリケーションに仕上がっています。具体的には 4, 11, 12, 14 章をスキップし、それ以外の各章もスタイルやインテグレーションテストについては省いています。(発展的なログイン機構、アカウント有効化のメール送信、パスワード再設定、ユーザーのフォローなどが機能として未実装。)

新規アプリ作成

rails _5.1.4_ new first_app
rails server
# => http://localhost:3000/

Gemfile 更新

feat: upadte Gemfile · ryotah/rails-tutorial@6336633

bundle install --without production

モデルを作成

User モデルと Micropost モデルを作成します。

User モデルを作成

rails generate model User name:string email:string password_digest:string
# => app/models/user.rb
# => db/migrate/xxx_create_users.rb
# => test/fixtures/user.yml
# => test/models/user_test.rb

rails db:migrate
# => db/schema.rb
# Rails 4以前
# bundle exec rake db:migrate

すでに存在するモデルにインデックスを追加。

rails generate migration add_index_to_users_email

生成されたdb/migrate/[timestamp]_add_index_to_users_email.rbを更新してから migrate を実行。

class AddIndexToUsersEmail < ActiveRecord::Migration[5.0]
  def change
    # 以下の行を追加
    #
    # unique: true => 一意性を強制
    add_index :users, :email, unique: true
  end
end

User モデルに validates, has_secure_password, has_many :microposts, dependent: :destroy などを追加。

class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum:  50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }, allow_nil: true

  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end
end

(メモ)rails console からユーザーを追加

user = User.create(name: "Michael Hartl", email: "mhartl@example.com", password: "foobar", password_confirmation: "foobar")

user.authenticate("foobaz")
# => false
!!user.authenticate("foobar")
# => true

Micropost モデル

rails generate model Micropost content:text user:references

生成されたdb/migrate/[timestamp]_create_microposts.rbを更新。

class CreateMicroposts < ActiveRecord::Migration[5.1]
  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user, foreign_key: true

      t.timestamps
    end
    # 以下の行を追加
    #
    # インデックスを追加して、user_id に関連付けられたマイクロポストを
    # 作成時刻の逆順で取り出しやすくする
    add_index :microposts, [:user_id, :created_at]
  end
end

Micropost モデルに validates, default_scope を追加。(belongs_to :user は最初から用意されている。)

class Micropost < ApplicationRecord
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

テスト

# 全テストを実行
rails test

# modelsのみテストを実行
rails test:models

# 特定ファイルのテストを実行
rails test test/models/micropost_test.rb

コントローラー作成

必要なControllerを作成。

rails generate controller StaticPages home
rails generate controller Users new
rails generate controller Sessions new
rails generate controller Microposts

ルーティングを設定

Rails.application.routes.draw do
  # root_path
  root 'static_pages#home'

  # 名前付きルートを定義
  get '/signup', to: 'users#new'
  post '/signup', to: 'users#create'
  get '/login', to: 'sessions#new'
  post '/login', to: 'sessions#create'
  delete '/logout', to: 'sessions#destroy'

  resources :users
  resources :microposts, only: [:create, :destroy]
end

セッションヘルパー

  • 以下 5 つを定義
    • ログイン
    • セッション情報から現在のユーザー(ログインユーザ)を取得
    • ログインユーザーか確認
    • ログイン済みか確認
    • ログアウト
module SessionsHelper
  def log_in(user)
    session[:user_id] = user.id
  end

  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def current_user?(user)
    user == current_user
  end

  def logged_in?
    !current_user.nil?
  end

  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

ApplicationControllerincludeする。(各ビューとコントローラーで利用できるようになる)

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

サンプルデータを追加

https://github.com/ryotah/rails-tutorial/blob/b27827169748425dbff08188d97c366eb73ec1fb/db/seeds.rb

rails db:seed

ビューとコントローラーを調整

ヘッダー

(メモ)デバッグ

<%= debug(params) if Rails.env.development? %>

ユーザー一覧

views/users/index.html.erbを追加。

<h1>Users#index</h1>
<%= render @users %>

views/users/_user.html.erbを追加。(render @usersに対応するパーシャル)

<div><%= user.name %>, <%= user.email %></div>

コントローラーにindexを追加。

class UsersController < ApplicationController
  # ...
  def index
    @users = User.all
  end
  # ...
end

ログイン

views/sessions/new.html.erbにフォームを追加。

<%= form_for(:session, url: login_path) do |f| %>
  <%= f.label :email %>
  <%= f.email_field :email, class: 'form-control' %>
  <%= f.label :password %>
  <%= f.password_field :password, class: 'form-control' %>
  <%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>

<p>New user? <%= link_to "Sign up now!", signup_path %></p>

コントローラーにcreateを追加。

def create
  user = User.find_by(email: params[:session][:email])
  if user && user.authenticate(params[:session][:password])
    log_in user
    redirect_to user
  else
    flash.now[:danger] = 'Invalid email/password combination'
    render 'new'
  end
end

(メモ)Flash

<% flash.each do |message_type, message| %>
  <div><%= message_type %> | <%= message %></div>
<% end %>

ユーザー作成

<%= form_for(@user, url: signup_path) do |f| %>
<%= render 'shared/error_messages', object: f>
# ...

ユーザー詳細

ホーム(マイクロポスト作成)

  • feat: post a micropost · ryotah/rails-tutorial@98a775c
    • ログイン済みユーザーかどうか確認するlogged_in_userApplicationControllerに追加
    • MicropostsControllercreateを実装
      • before_action :logged_in_user, only: :create
    • app/views/static_pages/home.html.erbにマイクロポスト用のフォームを追加
<h1>StaticPages#home</h1>
<p>Find me in app/views/static_pages/home.html.erb</p>  <p>Find me in app/views/static_pages/home.html.erb</p>
<% if logged_in? %>
  <%= current_user.name %>, <%= current_user.email %>
  <%= form_for(@micropost) do |f| %>
    <%= render 'shared/error_messages', object: f.object %>
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
    <%= f.submit "Post", class: "btn btn-primary" %>
  <% end %>
<% end %>

元記事のメモ/リンク

@user = users(:michael)
# このコードは慣習的に正しくない
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)

Rails ガイドより

users = User.all
user = User.first
david = User.find_by(name: 'David')

2月メモ・リンク集

2月に調べたことのメモです。

f:id:ryotah:20180303165052p:plain f:id:ryotah:20180303165054p:plain

リサイザー

@Directive({
  selector: '[grabber]',
})
export class GrabberDirective {
  // ...
  constructor(
    private elm: ElementRef,
    @Host() @SkipSelf() private resizable: ResizableDirective
  ) { }
  // ...
}

Show More/Less コンポーネント

function getLineHeight(element: HTMLElement): number {
  let lineHeight = parseInt(
    window.getComputedStyle(element, undefined).getPropertyValue('lineHeight'),
    10
  );
  if (isNaN(lineHeight)) {
    const clone = element.cloneNode() as HTMLElement;
    clone.innerHTML = '<br>';
    element.appendChild(clone);
    lineHeight = clone.clientHeight;
    element.removeChild(clone);
  }
  return lineHeight;
}

ドラッグアンドドロップdnd

実装の必要がなくなったので、参考にしようとしたリンクのみ。

Googleスプレッドシート的なスクロール

  • angular-scrollbar-like-spreadsheet - StackBlitz
    • コンテンツ(A)とスクロールエリア(B)を用意
    • A自体のスクロールバーは利用しない
    • Bの内部の要素をAの高さに合わせる
    • Bのスクロールに合わせてAを移動、A上発生したホイールイベントを利用してBを移動
// スクロール内部の高さを対象の高さに合わせる
const height = this.contentInner.nativeElement.clientHeight;
this.scrollInner.nativeElement.style.height = `${height}px`;

// スクロール時に
this.scroll.nativeElement.addEventListener('scroll', (e) => {      
  this.content.nativeElement.scrollTop = e.target.scrollTop;
});

// コンテンツエリアでマウホイールイベント発生時に
this.content.nativeElement.addEventListener('wheel', (e) => {
  const result = this.content.nativeElement.scrollTop + e.deltaY
  this.content.nativeElement.scrollTop = result;
  this.scroll.nativeElement.scrollTop = result;
});

AngularのDIに関して

Ruby, Rails関連

VS CodeでRubyを書く

.vscode/settings.json

"ruby.lint": {
  "rubocop": true,
  "ruby": {
      "unicode": true //Runs ruby -wc -Ku
  },
},

その他