Test your app
Every Nutin application ships with testin-nutin, a built-in testing toolkit.
Enable application tests
// nutin.config.js
testinNutin: {
includeFramework: true, // Nutin's own tests - set to false to only run yours
includeTools: false,
includeApp: true, // Include application tests
/* ... */
},
Create the test files
touch src/app/services/task/task.service.test.js src/app/views/task-catalog/task-catalog.view.test.js src/app/components/new-task/new-task.component.test.js src/app/components/task-action/task-action.component.test.js src/app/components/task-card/task-card.component.test.js src/app/components/task-inputs/task-inputs.component.test.js
src/app/
|---- components/
|---- new-task/new-task.component.test.js
|---- task-action/task-action.component.test.js
|---- task-card/task-card.component.test.js
|---- task-inputs/task-inputs.component.test.js
|---- services/
|---- task/task.service.test.js
|---- views/
|---- task-catalog/task-catalog.view.test.js
- Tests run against the compiled output in
dist/, which is why test files are.js. #root/points to your project root.describe,it,expect,spyOn,click,$,$... are globals and need no import.- Note: With
includeAppenabled, the generator creates a*.test.jsfile alongside every new component, view and service. - Refer to testin-nutin docs for details.
Run the tests
npm run testin-nutin # Builds, then runs the tests once
npm run testin-nutin task # Only runs test files whose path contains "task"
npm run testin-nutin:watch # Re-runs the tests on file changes
npm run testin-nutin:verbose # Logs test suites & individual tests
Test the service
// src/app/services/task/task.service.test.js
import { AppEventBus } from '#root/dist/src/core/index.js';
import { TaskService } from '#root/dist/src/app/services/task/task.service.js';
describe('TaskService', () => {
let service;
let emitSpy;
// Hooks are declared inside describe()
beforeEach(() => {
// Services are singletons: start every test from a fresh instance.
TaskService.testingReset();
service = TaskService.getInstance();
// Record emitted events without notifying real listeners.
emitSpy = spyOn(AppEventBus, 'emit').andCallFake(() => {});
});
afterEach(() => {
emitSpy.restore();
});
it('createTask adds a task with the next id', () => {
service.createTask();
service.createTask();
expect(service.tasks).toEqual([
{ id: 0, name: 'Task 1' },
{ id: 1, name: 'Task 2' },
]);
});
it('createTask emits task-event', () => {
service.createTask();
expect(emitSpy).toHaveBeenCalledWith('task-event', { taskId: 0 });
});
it('deleteTask removes the task', () => {
service.createTask();
service.deleteTask(0);
expect(service.tasks).toEqual([]);
});
it('updateTask replaces the task', () => {
service.createTask();
service.updateTask({ id: 0, name: 'Renamed', content: 'Details' });
expect(service.getTask(0)).toEqual({ id: 0, name: 'Renamed', content: 'Details' });
});
});
Test a component
Render it and click it
// src/app/components/new-task/new-task.component.test.js
import { NewTaskComponent } from '#root/dist/src/app/components/new-task/new-task.component.js';
import { taskService } from '#root/dist/src/app/services/task/task.service.js';
describe('NewTaskComponent', () => {
let app;
beforeEach(() => {
// The test page already contains your app's #app element.
app = document.getElementById('app');
});
afterEach(() => {
app.innerHTML = '';
});
it('clicking the button creates a task', () => {
// Mock the service method: calls are recorded, the real task list is untouched.
const createSpy = spyOn(taskService, 'createTask').andCallFake(() => {});
new NewTaskComponent(app).render();
click($('.new-task button'));
expect(createSpy).toHaveBeenCalled();
createSpy.restore();
});
});
Pass it a fake callback
// src/app/components/task-action/task-action.component.test.js
import { TaskActionComponent } from '#root/dist/src/app/components/task-action/task-action.component.js';
describe('TaskActionComponent', () => {
let app;
beforeEach(() => {
app = document.getElementById('app');
});
afterEach(() => {
app.innerHTML = '';
});
it('renders its text and className', () => {
const action = new TaskActionComponent(
app,
{ callback: () => {}, textContent: 'Remove' },
{ className: 'danger' },
);
action.render();
expect($('.task-action button').textContent).toBe('Remove');
expect(action.getElement().classList.contains('danger')).toBe(true);
});
it('clicking the button calls the callback', () => {
let calls = 0;
new TaskActionComponent(app, { callback: () => calls++, textContent: 'Remove' }).render();
click($('.task-action button'));
expect(calls).toBe(1);
});
});
Test its children and navigation
// src/app/components/task-card/task-card.component.test.js
import { Navigation } from '#root/dist/src/core/index.js';
import { TaskCardComponent } from '#root/dist/src/app/components/task-card/task-card.component.js';
import { taskService } from '#root/dist/src/app/services/task/task.service.js';
describe('TaskCardComponent', () => {
let app;
beforeEach(() => {
app = document.getElementById('app');
});
afterEach(() => {
app.innerHTML = '';
});
it('renders the task name', () => {
new TaskCardComponent(app, { id: 1, name: 'Buy milk' }).render();
expect($('.task-card h2').textContent).toBe('Buy milk');
});
it('removes the content paragraph when the task has no content', () => {
new TaskCardComponent(app, { id: 1, name: 'Buy milk' }).render();
expect($('.task-card p')).toBe(null);
});
it('renders its Edit and Remove actions', () => {
new TaskCardComponent(app, { id: 1, name: 'Buy milk' }).render();
const labels = $('.task-action button').map((button) => button.textContent);
expect(labels).toEqual(['Edit', 'Remove']);
});
it('Remove deletes the task', () => {
const deleteSpy = spyOn(taskService, 'deleteTask').andCallFake(() => {});
new TaskCardComponent(app, { id: 1, name: 'Buy milk' }).render();
click($('.task-card__action-remove button'));
expect(deleteSpy).toHaveBeenCalledWith(1);
deleteSpy.restore();
});
it('Edit navigates to the task route', () => {
const navigateSpy = spyOn(Navigation, 'navigateTo').andCallFake(() => {});
new TaskCardComponent(app, { id: 1, name: 'Buy milk' }).render();
click($('.task-action button')[0]);
expect(navigateSpy).toHaveBeenCalledWith('/tasks/1');
navigateSpy.restore();
});
});
Test user input
// src/app/components/task-inputs/task-inputs.component.test.js
import { TaskInputsComponent } from '#root/dist/src/app/components/task-inputs/task-inputs.component.js';
import { taskService } from '#root/dist/src/app/services/task/task.service.js';
describe('TaskInputsComponent', () => {
let app;
let updateSpy;
beforeEach(() => {
app = document.getElementById('app');
updateSpy = spyOn(taskService, 'updateTask').andCallFake(() => {});
});
afterEach(() => {
updateSpy.restore();
app.innerHTML = '';
});
// The inputs listen to `change`, which fires when the user commits a new value.
const change = (el, value) => {
el.value = value;
el.dispatchEvent(new window.Event('change'));
};
it('fills the inputs with the task', () => {
new TaskInputsComponent(app, { id: 1, name: 'Buy milk', content: 'Oat milk' }).render();
expect($('#name').value).toBe('Buy milk');
expect($('#content').value).toBe('Oat milk');
});
it('leaves the content empty when the task has none', () => {
new TaskInputsComponent(app, { id: 1, name: 'Buy milk' }).render();
expect($('#content').value).toBe('');
});
it('changing the name updates the task', () => {
new TaskInputsComponent(app, { id: 1, name: 'Buy milk' }).render();
change($('#name'), 'Buy bread');
expect(updateSpy).toHaveBeenCalledWith({ id: 1, name: 'Buy bread' });
});
it('keeps previous edits when changing another field', () => {
new TaskInputsComponent(app, { id: 1, name: 'Buy milk' }).render();
change($('#name'), 'Buy bread');
change($('#content'), 'Whole wheat');
expect(updateSpy.lastCall).toEqual([{ id: 1, name: 'Buy bread', content: 'Whole wheat' }]);
});
});
Test the view
// src/app/views/task-catalog/task-catalog.view.test.js
import { AppEventBus } from '#root/dist/src/core/index.js';
import { TaskCatalogView } from '#root/dist/src/app/views/task-catalog/task-catalog.view.js';
import { taskService } from '#root/dist/src/app/services/task/task.service.js';
describe('TaskCatalogView', () => {
let view;
let emitSpy;
beforeEach(() => {
emitSpy = spyOn(AppEventBus, 'emit').andCallFake(() => {});
taskService.createTask();
taskService.createTask();
view = new TaskCatalogView();
});
afterEach(() => {
// The view listens to AppEventBus: destroy it so its listeners don't leak into other tests.
view.destroy();
taskService.tasks.forEach((task) => taskService.deleteTask(task.id));
emitSpy.restore();
});
it('renders one card per task', () => {
view.render();
expect($('.task-card').length).toBe(2);
});
it('does not render task inputs without an id', () => {
view.render();
expect($('.task-inputs')).toBe(null);
});
it('renders task inputs for the task in the route', () => {
// What the router does for /tasks/1
view.setRouteParams({ id: '1' });
view.render();
expect($('.task-inputs #name').value).toBe('Task 2');
});
});