A guide to TDD a React/Redux TodoList App — Part 4
【所要時間】
4時間11分(2018年9月17,18日)
【概要】
TDDによるReact/Redux App チュートリアル 4
【要約・学んだこと】
The TodoList component
e2eテストをパスするためにやることがまだある。 .todo-text elementを持つことと、それがformに送ったtextを含むことだ。
componentを作ることを許可するtodoList/test.jsを作る。
//src/components/todoList/test.js/* global describe, it, expect */import React from 'react';
import { shallow } from 'enzyme';
import TodoList from '.';describe('TodoList component', () => {
const component = shallow(<TodoList />);it('Should render successfully', () => {
expect(component.exists()).toEqual(true);
});
});
これは自己説明的なもので、addTodo componentの作り始め方に似ている。
componentはindex.jsでreturnする。
//src/components/todoList/index.jsimport React from 'react';const TodoList = () => (
<ul />
);export default TodoList;
<ul>にこのcomponentを包む。
このリストでtodoをテストする。
//src/components/todoList/test.js/* global describe, it, expect */import React from 'react';
import { shallow } from 'enzyme';
import TodoList from '.';describe('TodoList component', () => {
const todos = [
{
id: 1,
text: 'A todo',
},
];const component = shallow(<TodoList todos={todos} />);it('Should render successfully', () => {
expect(component.exists()).toEqual(true);
});it('Should display a todo when passed in as a prop', () => {
expect(component.find('.todo-text').text()).toEqual(todos[0].text);
});
});
const todos = [ { id: 1, text: ‘A todo’, }, ];
todos propを作る。このarrayはobjectとしてidとtextを含む。
const component = shallow(<TodoList todos={todos} />);
実際にpropとしてcomponentにtodoをパスする。
expect(component.find(‘.todo-text’).text()).toEqual(todos[0].text);
todoListが.todo-text elementにtodo textを含むことを期待する。
すぐにtodoをrenderする必要がある。
import React from 'react';
import PropTypes from 'prop-types';const TodoList = ({ todos }) => {
const todoItems = todos.map(todo => (
<li key={todo.id}>
<span className="todo-text">{todo.text}</span>
</li>
));return (
<ul>
{todoItems}
</ul>
);
};TodoList.propTypes = {
todos: PropTypes.arrayOf(PropTypes.shape(
{
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
},
)).isRequired,
};export default TodoList;TodoList.propTypes = {
todos: PropTypes.arrayOf(PropTypes.shape(
{
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
},
)).isRequired,
};
componentにtodosと呼ばれるpropを受け取り、それがobjectのarrayになり、idがnumber, textがstring,そして全てがrequiredされるように伝える。React prop-types validation.
const TodoList = ({ todos }) => {
todos propを実際のcomponentに渡す
const todoItems = todos.map(todo => (
<li key={todo.id}>
<span className="todo-text">{todo.text}</span>
</li>
arrrayをmapし、中身がtodo.textで、todo.idというkeyを持つの liをreturnする。keyはReactに要求され、どうやってDOMをre-renderするかを効果的に知る。また、それがなければconsole warningをdivで受け取る。
return (
<ul>
{todoItems}
</ul>
);
それぞれのtodoの全てのli を持った ul wrapperをreturnする。
Displaying todos
この時点でTodoList componentはどうやってそれ自身をrenderするか知るので、main app と結び付けられる。
//src/App.jsimport React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import AddTodo from './components/addTodo/';
import TodoList from './components/todoList';
import actions from './actions/';export const App = ({ submitTodo, todos }) => (
<div>
<h1>Todo list</h1>
<AddTodo submitTodo={submitTodo} />
<TodoList todos={todos} />
</div>
);App.propTypes = {
submitTodo: PropTypes.func.isRequired,
todos: PropTypes.arrayOf(PropTypes.shape(
{
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
},
)).isRequired,
};const mapStateToProps = state => state.todoListApp;const mapDispatchToProps = dispatch => ({
submitTodo: (text) => {
if (text) {
dispatch(actions.submitTodo(text));
}
},
});export default connect(mapStateToProps, mapDispatchToProps)(App);todos: PropTypes.arrayOf(PropTypes.shape(
{
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
},
)).isRequired,
適切な検証を供給する。
export const App = ({ submitTodo, todos }) => (
propとしてAppのstateからtodos arrayをとるためにそれをパスする。
const mapStateToProps = state => state.todoListApp; のおかげでpropが利用できる。
<TodoList todos={todos} />
Todo List componentをAppに加え、stateからtodosをパスする。
ここではcomponentにtodos arrayを求めるが、まだパスしていないのでエラーになる。
そこを修正する。
//src/App.test.js/* global it, expect, jest */import React from 'react';
import { shallow } from 'enzyme';
import { App } from './App';
import { initialState } from './reducers/';it('App renders without crashing', () => {
const mockFunction = jest.fn();const component = shallow(
<App
state={initialState}
submitTodo={mockFunction}
todos={[]}
/>,
);expect(component.exists()).toEqual(true);
});
todos={[]}
empty arrayをtodos propに加える。これでApp.testの警告が消える。親levelで子componentのunit testはしたくない。
これで入力した内容が表示されるようになった。
e2d testもパスした。
Deleting a todo
まず e2e test を書く。
/* global describe, it, browser */const expect = require('chai').expect;describe('TodoList App', () => {
it('Should load with the right title', () => {
browser.url('http://localhost:3000/');
const actualTitle = browser.getTitle();expect(actualTitle).to.eql('Todo List');
});it('Should allow me to create a Todo', () => {
const todoText = 'Get better at testing';
browser.url('http://localhost:3000/');
browser.element('.todo-input').setValue(todoText);
browser.click('.todo-submit');
browser.click('.todo-delete');
const actual = browser.element('.todo-text'); expect(actual.state).to.equal('failure');
}); it('Should allow me to delete a Todo', () => {
const todoText = 'Get better at testing';
browser.url('http://localhost:3000/');
browser.element('.todo-input').setValue(todoText);
browser.click('.todo-submit');
browser.click('.todo-delete');
const actual = browser.element('.todo-text'); expect(actual.state).to.equal('failure');
});
});
このテストでは前回と同様のプロセスで行う。.todo-delete elementをクリックし、selectorのstateがfailureになることを期待する。もしそれが存在している(消されていない)と’success’とイコールになる。
これはaction, reducer, creating the component, それらをAppに加えるというライティングによるfunctionalityの実装に似ている。
正しいactionをテストするために、正しいconstantを作る。
//src/constants/index.jsconst types = {
SUBMIT_TODO: 'SUBMIT_TODO',
DELETE_TODO: 'DELETE_TODO',
};export default types;
あらたにDELETE_TODO constantを加えた。
actionでは、todoをdeleteすると、それが独自のidになることだけ知れば良い。
//src/actions/test.js/* global expect, it, describe */import actions from '.';
import types from '../constants/';describe('Actions', () => {
const todoText = 'A todo';it('Should create an action to add a todo', () => {
const expectedAction = {
type: types.SUBMIT_TODO,
id: 1,
text: todoText,
};
expect(actions.submitTodo(todoText)).toEqual(expectedAction);
});it('Should create an action to delete a todo', () => {
const expectedAction = {
type: types.DELETE_TODO,
id: 1,
};expect(actions.deleteTodo(1)).toEqual(expectedAction);
});
});
actionのindex.jsも修正する。
//src/actions/index.jsimport types from '../constants/';let todoId = 0;const nextId = () => {
todoId += 1;
return todoId;
};const actions = {
submitTodo(text) {
return {
type: types.SUBMIT_TODO,
id: nextId(),
text,
};
},deleteTodo(id) {
return {
type: types.DELETE_TODO,
id,
};
},
};export default actions;
reducer functionalityのunit testも行う
//src/reducers/test.js/* global expect, it, describe */import types from '../constants/';
import { reducer, initialState } from '.';describe('Reducer', () => {
const todoText = 'A todo';it('Should return the initial state when no action passed', () => {
expect(reducer(undefined, {})).toEqual(initialState);
});describe('Submit todo', () => {
it('Should return the correct state', () => {
const action = {
type: types.SUBMIT_TODO,
id: 1,
text: todoText,
};const expectedState = {
todos: [
{
id: 1,
text: todoText,
},
],
};expect(reducer(undefined, action)).toEqual(expectedState);
});
});describe('Delete todo', () => {
it('Should return the correct state', () => {
const startingState = {
todos: [
{
id: 1,
text: todoText,
},
],
};const action = {
type: types.DELETE_TODO,
id: 1,
};const expectedState = {
todos: [],
};expect(reducer(startingState, action)).toEqual(expectedState);
});
});
});
これはたくさんの意味を持つ。reducerにtodoを含むstartingStateを私、storeからtodoをdeleteするのに作ったactionに渡し、returnされたstateが本当にtodoを含まないことを期待する。
これを扱うためにreducerにcaseを追加する必要がある。
//src/reducers/index.jsimport types from '../constants/';export const initialState = {
todos: [],
};export const reducer = (state = initialState, action) => {
switch (action.type) {case types.SUBMIT_TODO:
return {
...state,
todos: [
...state.todos,
{
id: action.id,
text: action.text,
},
],
};case types.DELETE_TODO:
return {
...state,
todos: [
...state.todos.filter(todo => (
todo.id !== action.id
)),
],
};default:
return state;
}
};export default reducer;
DLETE_TODO action.typeに遭遇するとき、存在する… stateを含む全ての新しいstateをreturnする。そしてtodo.idがaction.idと同じでない場所のみがフィルターされ、todos arrayが上書きされる。
次はcomponentがtodoをdeleteすることを許可できるかどうかのテスト。
//src/components/todoList/test.js/* global describe, it, expect, jest */import React from 'react';
import { shallow } from 'enzyme';
import TodoList from '.';describe('TodoList component', () => {
const deleteMock = jest.fn();const props = {
todos: [
{
id: 1,
text: 'A todo',
},
],
deleteTodo: deleteMock,
};const component = shallow(<TodoList {...props} />);it('Should render successfully', () => {
expect(component.exists()).toEqual(true);
});it('Should display a todo when passed in as a prop', () => {
expect(component.find('.todo-text').text()).toEqual(props.todos[0].text);
});it('Should call the deleteTodo function when Delete button is clicked', () => {
expect(deleteMock.mock.calls.length).toEqual(0);
component.find('.todo-delete').simulate('click');
expect(deleteMock.mock.calls.length).toEqual(1);
});
});
const deleteMock = jest.fn();
delete functionalityのためのmock functionを作る。
const props = {
todos: [
{
id: 1,
text: 'A todo',
},
],
deleteTodo: deleteMock,
};
以前のsingle todos propを必要な全てのpropを保持するobjectにrefactorする。
const component = shallow(<TodoList {…props} />);
componentがshallowをrenderするのを許可し、以前に指定されたpropsはすべてunpackされる。
it('Should call the deleteTodo function when Delete button is clicked', () => {
expect(deleteMock.mock.calls.length).toEqual(0);
component.find('.todo-delete').simulate('click');
expect(deleteMock.mock.calls.length).toEqual(1);
});
.todo-delete elementをクリックすると確かにdeleteTodo functionをコールするかのテスト。
//src/components/todoList/index.jsimport React from 'react';
import PropTypes from 'prop-types';const TodoList = ({ todos, deleteTodo }) => {
const todoItems = todos.map(todo => (
<li key={todo.id}><button
type="button"
className="todo-delete"
onClick={() => deleteTodo(todo.id)}
>
Delete
</button><span className="todo-text">
{todo.text}
</span>
</li>
));return (
<ul>
{todoItems}
</ul>
);
};TodoList.propTypes = {
todos: PropTypes.arrayOf(PropTypes.shape(
{
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
},
)).isRequired,
deleteTodo: PropTypes.func.isRequired,
};export default TodoList;
deleteTodo: PropTypes.func.isRequired,
deleteTodo propを検証する
const TodoList = ({ todos, deleteTodo }) => {
componentにそれをパスする。
<button
type="button"
className="todo-delete"
onClick={() => deleteTodo(todo.id)}
>
Delete
</button>
onClick イベントのargumentとしてtodo.idを指定し、deleteTodo functionをコールするDelete buttonを追加する。
次はAppを介してUIに繋ぐ。
//src/App.jsimport React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import AddTodo from './components/addTodo/';
import TodoList from './components/todoList';
import actions from './actions/';export const App = ({ submitTodo, todos, deleteTodo }) => (
<div>
<h1>Todo list</h1>
<AddTodo submitTodo={submitTodo} />
<TodoList todos={todos} deleteTodo={deleteTodo} />
</div>
);App.propTypes = {
submitTodo: PropTypes.func.isRequired,
todos: PropTypes.arrayOf(PropTypes.shape(
{
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
},
)).isRequired,
deleteTodo: PropTypes.func.isRequired,
};const mapStateToProps = state => state.todoListApp;const mapDispatchToProps = dispatch => ({
submitTodo: (text) => {
if (text) {
dispatch(actions.submitTodo(text));
}
},deleteTodo: (id) => {
dispatch(actions.deleteTodo(id));
},
});export default connect(mapStateToProps, mapDispatchToProps)(App);deleteTodo: (id) => {
dispatch(actions.deleteTodo(id));
},
適切にactionをdispatchするdeleteTodo propを作る。
deleteTodo: PropTypes.func.isRequired,
それを検証する。
export const App = ({ submitTodo, todos, deleteTodo }) => (
それをcomponentのためにpropに加える。
<TodoList todos={todos} deleteTodo={deleteTodo} />
それをTodoList componentに加える。
これでUnit testとe2eテストはパスし、Appがブラウザで機能する。
Delete できた。
テストは
うまくいかない。App.test.jsのdeleteTodoがおかしいということだが、delteTodoに関して何も加えていなかったので、追加する。
const component = shallow(
<App
state={initialState}
submitTodo={mockFunction}
todos={[]}
deleteTodo={mockFunction}
/>,
);
これでunit testはパスした。
e2e-testもパスした。
Undelete a todo
今度はdeleteを取り消せるようにする。流れとしては
1.e2e test を書く。it undelete a Todoを追加。
//e2etests/test.jsit('Should allow me to undelete a Todo', () => {
const todoText = 'Get better at testing';
browser.url('http://localhost:3000/');
browser.element('.todo-input').setValue(todoText);
browser.click('.todo-submit');
browser.click('.todo-delete');
browser.click('.todo-undelete');
const actual = browser.element('.todo-text').getText(); expect(actual).to.equal('todoText');
});
undeleteした後に、todo-submitをした後と同じ状態に戻っていればいいので、ここではactualと’todoText’が同じになっていれば良い。
2. 正しいactionをテストするために、正しいconstantを作る。
//constants/index.jsconst types = {
SUBMIT_TODO: 'SUBMIT_TODO',
DELETE_TODO: 'DELETE_TODO',
UNDELETE_TODO: 'UNDELETE_TODO',
};export default types;
UNDELETE_TODO constantを追加。
3. actionsのtestにundeleteを追加。
//actions/test.jsit('Should create an action to undelete a todo', () => {
const expectedAction = {
type: types.UNDELETE_TODO,
};expect(actions.undeleteTodo()).toEqual(expectedAction);
});
4. actionのindex.jsも修正する。
//actions/index.jsundeleteTodo() {
return {
type: types.UNDELETE_TODO,
id,
};
},
5. reducer functionalityのunit testも行う
//reducers/test.js/* global expect, it, describe */import types from '../constants/';
import { reducer, initialState } from '.';describe('Reducer', () => {
const todoText = 'A todo';it('Should return the initial state when no action passed', () => {
expect(reducer(undefined, {})).toEqual(initialState);
});describe('Submit todo', () => {
it('Should return the correct state', () => {
const action = {
type: types.SUBMIT_TODO,
id: 1,
text: todoText,
};const expectedState = {
todos: [
{
id: 1,
text: todoText,
},
],
deleted: {},
};expect(reducer(undefined, action)).toEqual(expectedState);
});
});describe('Delete todo', () => {
it('Should return the correct state', () => {
const startingState = {
todos: [
{
id: 1,
text: todoText,
},
],
deleted: {},
};const action = {
type: types.DELETE_TODO,
id: 1,
};const expectedState = {
todos: [],
deleted: {
id: 1,
text: todoText,
},
};expect(reducer(startingState, action)).toEqual(expectedState);
});
});describe('Undelete todo', () => {
it('Should return the correct state', () => {
const startingState = {
todos: [],
deleted: {
id: 1,
text: todoText,
},
};const action = {
type: types.UNDELETE_TODO,
};const expectedState = {
todos: [
{
id: 1,
text: todoText,
},
],
deleted: {},
};expect(reducer(startingState, action)).toEqual(expectedState);
});
});
});
6. これを扱うためにreducerにcaseを追加する。
//reducers/index.jsimport types from '../constants/';export const initialState = {
todos: [],
deleted: {},
};export const reducer = (state = initialState, action) => {
switch (action.type) {case types.SUBMIT_TODO:
return {
...state,
todos: [
...state.todos,
{
id: action.id,
text: action.text,
},
],
};case types.DELETE_TODO:
return {
...state,
todos: [
...state.todos.filter(todo => (
todo.id !== action.id
)),
],
deleted: state.todos.filter(todo => todo.id === action.id)[0],
};case types.UNDELETE_TODO:
return {
...state,
todos: [
...state.todos,
state.deleted,
],
deleted: {},
};default:
return state;
}
};export default reducer;
7.addTodoがundeleteできるかどうかのテスト。
//addTodo/test.js/* global expect, it, describe, jest, beforeEach */import React from 'react';
import { shallow, mount } from 'enzyme';
import AddTodo from '.';describe('AddTodo component', () => {
let component;
const submitMock = jest.fn();
const undeleteMock = jest.fn();beforeEach(() => {
component = shallow(
<AddTodo
submitTodo={submitMock}
undeleteTodo={undeleteMock}
/>,
);
});it('Should render successfully', () => {
expect(component.exists()).toEqual(true);
});it('Should have one input', () => {
expect(component.find('.todo-input').length).toEqual(1);
});describe('Add todo button', () => {
it('Should exist', () => {
expect(component.find('.todo-submit').length).toEqual(1);
});it('Should call the submitTodo function when clicked', () => {
component = mount(<AddTodo submitTodo={submitMock} undeleteTodo={undeleteMock} />);expect(submitMock.mock.calls.length).toEqual(0);
component.find('form').simulate('submit');
expect(submitMock.mock.calls.length).toEqual(1);
});
});describe('Undelete button', () => {
it('Should exist', () => {
expect(component.find('.todo-undelete').length).toEqual(1);
});it('Should call the undeleteTodo function when clicked', () => {
component = mount(<AddTodo submitTodo={submitMock} undeleteTodo={undeleteMock} />);expect(undeleteMock.mock.calls.length).toEqual(0);
component.find('.todo-undelete').simulate('click');
expect(undeleteMock.mock.calls.length).toEqual(1);
});
});
});
8.undelete button を追加する。
//addTodo/index.jsimport React from 'react';
import PropTypes from 'prop-types';const AddTodo = ({ submitTodo, undeleteTodo }) => {
let input;return (
<div>
<form
onSubmit={(event) => {
event.preventDefault();
submitTodo(input.value);
input.value = '';
}}
><input
className="todo-input"
ref={(element) => {
input = element;
}}
/><button type="submit" className="todo-submit">
Add Todo
</button><button
className="todo-undelete"
onclick={() => undeleteTodo()}
>
Undelete
</button>
</form>
</div>
);
};AddTodo.propTypes = {
submitTodo: PropTypes.func.isRequired,
undeleteTodo: PropTypes.func.isRequired,
};export default AddTodo;
9. undelete button をAppにも反映させる。
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import AddTodo from './components/addTodo/';
import TodoList from './components/todoList';
import actions from './actions/';export const App = ({ submitTodo, todos, deleteTodo, undeleteTodo }) => (
<div>
<h1>Todo list</h1>
<AddTodo submitTodo={submitTodo} />
<AddTodo submitTodo={submitTodo} undeleteTodo={undeleteTodo}/>
<TodoList todos={todos} deleteTodo={deleteTodo} />
</div>
);App.propTypes = {
submitTodo: PropTypes.func.isRequired,
todos: PropTypes.arrayOf(PropTypes.shape(
{
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
},
)).isRequired,
deleteTodo: PropTypes.func.isRequired,
undeleteTodo: PropTypes.func.isRequired,
};const mapStateToProps = state => state.todoListApp;
const mapDispatchToProps = dispatch => ({
submitTodo: (text) => {
if (text) {
dispatch(actions.submitTodo(text));
}
},deleteTodo: (id) => {
dispatch(actions.deleteTodo(id));
},undeleteTodo: () => {
dispatch(actions.undeleteTodo());
},
});export default connect(mapStateToProps, mapDispatchToProps)(App);
10. AppのテストのcomponentにundeleteTodoを追加
//src/App.test.jsconst component = shallow(
<App
state={initialState}
submitTodo={mockFunction}
todos={[]}
deleteTodo={mockFunction}
undeleteTodo={mockFunction}
/>,
);
11. Appにundelete buttonをインポートし、追加する。
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import AddTodo from './components/addTodo/';
import TodoList from './components/todoList';
import actions from './actions/';export const App = ({ submitTodo, todos, deleteTodo, undeleteTodo }) => (
<div>
<h1>Todo list</h1>
<AddTodo submitTodo={submitTodo} undeleteTodo={undeleteTodo}/>
<TodoList todos={todos} deleteTodo={deleteTodo} />
</div>
);App.propTypes = {
submitTodo: PropTypes.func.isRequired,
todos: PropTypes.arrayOf(PropTypes.shape(
{
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
},
)).isRequired,
deleteTodo: PropTypes.func.isRequired,
undeleteTodo: PropTypes.func.isRequired,
};const mapStateToProps = state => state.todoListApp;const mapDispatchToProps = dispatch => ({
submitTodo: (text) => {
if (text) {
dispatch(actions.submitTodo(text));
}
},deleteTodo: (id) => {
dispatch(actions.deleteTodo(id));
},undeleteTodo: () => {
dispatch(actions.undeleteTodo());
},
});export default connect(mapStateToProps, mapDispatchToProps)(App);
2ndの内容をdelete
undeleteで2ndの内容を復元
Unit test, e2e-tests共にパスした。
【わからなかったこと】
undeleteを追加した時にe2e testが通過しなかった。
間違ったテストコードit('Should allow me to undelete a Todo', () => {
const todoText = 'Get better at testing';
browser.url('http://localhost:3000/');
browser.element('.todo-input').setValue(todoText);
browser.click('.todo-submit');
browser.click('.todo-delete');
browser.click('.todo-undelete');
const actual = browser.element('.todo-text');expect(actual.state).to.equal('success');
});
This test runs through the same procedure as the one before and then clicks the
.todo-delete
element and then expects the selector’sstate
to equal ‘failure’. If it existed (was not deleted), it would equal ‘success’.
という説明があったので、todo-textをdeleteした後に、再びdeleteする前にundeleteと、actual.stateはsuccessになるはずだと思ったが、undefinedとなる。
正しいテストコード it('Should allow me to undelete a Todo', () => {
const todoText = 'Get better at testing';
browser.url('http://localhost:3000/');
browser.element('.todo-input').setValue(todoText);
browser.click('.todo-submit');
browser.click('.todo-delete');
browser.click('.todo-undelete');
const actual = browser.element('.todo-text').getText();expect(actual).to.equal(todoText);
});
そうではなく、undeleteすると、todo-submitした後と同じ状態になるのを確認すればいいので、上記のコードでテストすれば良い。
const actual = browser.element('.todo-text').getText();expect(actual).to.equal(todoText);
は、todo-submitが正しい。
今回これに気がつけなかったが、console.logを使うことでvalueを確認することができるし、そもそも何のテストをしたいか。というのを見失わなければ、気がつくことができたと思う。
【感想】
unit test、e2e-testをそれぞれ行うことで、アプリがうまく動作しない時にどこに原因があるかわかりやすくなる。テストコードを書くのが大変そうだが、作成するアプリが複雑になる程、のちに簡単に作成できそうだ。