From 23b9a8ec5efe20606dba686a1f6ceeb6d0d369c4 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Mon, 5 Jan 2026 17:56:33 +0200 Subject: [PATCH] test: add frontend tests --- .prettierignore | 2 +- README.md | 266 ++++++++++++ gen/component/{{pascalCase name}}.test.ts | 89 ++++ gen/component/{{pascalCase name}}.vue | 6 +- gen/view/{{pascalCase name}}Page.vue | 2 +- package.json | 8 +- pnpm-lock.yaml | 487 +++++++++++++++++++++- scaffold.config.cjs | 1 - src/components/StatusBadge.test.ts | 270 ++++++++++++ src/components/StatusBadge.vue | 148 +++++++ src/test-utils.ts | 60 +++ src/utils/string.test.ts | 137 ++++++ src/utils/string.ts | 70 ++++ src/views/AppView.vue | 24 +- vitest.config.ts | 18 + 15 files changed, 1576 insertions(+), 12 deletions(-) create mode 100644 gen/component/{{pascalCase name}}.test.ts create mode 100644 src/components/StatusBadge.test.ts create mode 100644 src/components/StatusBadge.vue create mode 100644 src/test-utils.ts create mode 100644 src/utils/string.test.ts create mode 100644 src/utils/string.ts create mode 100644 vitest.config.ts diff --git a/.prettierignore b/.prettierignore index a52a73c..40baa51 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ templates/ -scaffolds/ +gen/ diff --git a/README.md b/README.md index 31c150a..4107831 100755 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Once you have it cloned on your machine: - [Tips & gotchas](#tips--gotchas) - [GitHub Workflows](#github-workflows) - [Project layout](#project-layout) +- [Testing](#testing) - [Release Please (automated versioning & releases)](#release-please-automated-versioning--releases) - [Resources](#resources) @@ -296,6 +297,271 @@ them to `.github/workflows/`** so Actions start running automatically. └─ rename-template.sh # One-time renamer script for template cloning ``` +## Testing + +### Frontend Testing + +This template includes a complete frontend testing setup using [Vitest](https://vitest.dev/) and +[Vue Test Utils](https://test-utils.vuejs.org/). + +#### Running tests + +```bash +# Run tests in watch mode (recommended during development) +pnpm test + +# Run tests once (useful for CI) +pnpm test:run +``` + +#### Test file structure + +Test files are placed next to the files they test, using the `.test.ts` suffix: + +``` +src/ +├─ utils/ +│ ├─ string.ts # Utility functions +│ └─ string.test.ts # Tests for string.ts +├─ components/ +│ ├─ StatusBadge.vue # Vue component +│ └─ StatusBadge.test.ts # Tests for StatusBadge.vue +``` + +#### Writing tests + +##### Pure TypeScript/utility functions + +For utility functions, use the standard `describe`/`it`/`expect` pattern: + +```typescript +import { describe, expect, it } from 'vitest' +import { myFunction } from './myModule' + +describe('myFunction', () => { + it('handles normal input', () => { + expect(myFunction('hello')).toBe('HELLO') + }) + + it('handles null input', () => { + expect(myFunction(null)).toBe('') + }) +}) +``` + +##### Vue components + +For Vue components, you'll need to mock Nextcloud dependencies. Here's a typical pattern: + +```typescript +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import MyComponent from './MyComponent.vue' + +// Mock @nextcloud/l10n +vi.mock('@nextcloud/l10n', () => ({ + t: (app: string, text: string, vars?: Record) => { + if (vars) { + return Object.entries(vars).reduce( + (acc, [key, value]) => acc.replace(`{${key}}`, String(value)), + text, + ) + } + return text + }, + n: (app: string, singular: string, plural: string, count: number) => { + return count === 1 ? singular : plural + }, +})) + +// Mock Nextcloud Vue components +vi.mock('@nextcloud/vue/components/NcButton', () => ({ + default: { + name: 'NcButton', + template: + '', + props: ['variant', 'disabled', 'ariaLabel', 'title'], + }, +})) + +// Mock icon components +vi.mock('@icons/Check.vue', () => ({ + default: { name: 'CheckIcon', template: '', props: ['size'] }, +})) + +describe('MyComponent', () => { + it('renders with props', () => { + const wrapper = mount(MyComponent, { + props: { title: 'Hello' }, + }) + expect(wrapper.text()).toContain('Hello') + }) + + it('emits events', async () => { + const wrapper = mount(MyComponent, { + props: { clickable: true }, + }) + await wrapper.trigger('click') + expect(wrapper.emitted('click')).toBeTruthy() + }) + + it('computes values correctly', () => { + const wrapper = mount(MyComponent, { + props: { count: 5 }, + }) + // Access computed properties via wrapper.vm + expect((wrapper.vm as InstanceType).doubleCount).toBe(10) + }) +}) +``` + +#### Tips + +- **Test file location**: Place test files next to the files they test (e.g., `Component.test.ts` + next to `Component.vue`) +- **TypeScript errors**: You may see "Cannot find module './Component.vue'" errors in test files. + These can be ignored as Vitest handles Vue files correctly at runtime +- **Mocking**: Keep mocks minimal - only mock what's necessary for the test to run +- **happy-dom**: This template uses happy-dom instead of jsdom for faster test execution. Note that + happy-dom preserves hex colors (e.g., `#ff5500`) rather than converting to RGB +- **Globals**: The vitest config enables globals, so you don't need to import `describe`, `it`, + `expect` in every file (though explicit imports are recommended for clarity) + +#### Resources + +- [Vitest documentation](https://vitest.dev/) +- [Vue Test Utils documentation](https://test-utils.vuejs.org/) +- [Testing Vue 3 components](https://test-utils.vuejs.org/guide/) + +### Backend Testing (PHP) + +This template uses [PHPUnit](https://phpunit.de/) for PHP unit testing, integrated with the +Nextcloud testing framework. + +#### Running PHP tests + +There are two ways to run PHP tests: + +**Option 1: Docker (recommended)** + +```bash +make test-docker +``` + +This automatically finds a running Nextcloud container and runs tests inside it. Works with +[nextcloud-docker-dev](https://github.com/juliushaertl/nextcloud-docker-dev) and similar setups. + +**Option 2: Local Nextcloud installation** + +```bash +# Set NEXTCLOUD_ROOT to your Nextcloud server path +NEXTCLOUD_ROOT=~/path/to/nextcloud make test + +# Or set it in the Makefile (line 47) for convenience +make test +``` + +#### Test file structure + +PHP tests live in the `tests/` directory: + +``` +tests/ +├─ unit/ +│ └─ Controller/ +│ └─ ApiTest.php # Unit tests for ApiController +├─ bootstrap.php # Test bootstrap (loads Nextcloud environment) +├─ phpunit.xml # PHPUnit config for local testing +└─ phpunit.docker.xml # PHPUnit config for Docker testing +``` + +#### Writing PHP tests + +Here's an example test showing how to mock Nextcloud dependencies: + +```php +request = $this->createMock(IRequest::class); + $this->config = $this->createMock(IAppConfig::class); + $this->l10n = $this->createMock(IL10N::class); + + // Mock translation to return the input string + $this->l10n->method('t') + ->willReturnCallback(function ($text, $params = []) { + if (empty($params)) { + return $text; + } + return vsprintf($text, $params); + }); + + $this->controller = new ApiController( + Application::APP_ID, + $this->request, + $this->config, + $this->l10n + ); + } + + public function testGetHello(): void { + $this->config->method('getValueString') + ->willReturn(''); + + $resp = $this->controller->getHello()->getData(); + + $this->assertIsArray($resp); + $this->assertArrayHasKey('message', $resp); + } + + public function testPostHello(): void { + $this->config->expects($this->once()) + ->method('setValueString'); + + $resp = $this->controller->postHello([ + 'name' => 'World', + ])->getData(); + + $this->assertStringContainsString('World', $resp['message']); + } +} +``` + +#### Tips + +- **Mocking**: Use `$this->createMock()` for Nextcloud interfaces like `IRequest`, `IAppConfig`, + `IL10N`, etc. +- **Test isolation**: Each test should be independent; use `setUp()` to create fresh mocks +- **Naming convention**: Test files should end with `Test.php` (e.g., `ApiTest.php`) +- **Docker vs local**: Docker testing is more reliable as it uses a fully configured Nextcloud + environment + +#### Resources + +- [PHPUnit documentation](https://docs.phpunit.de/) +- [Nextcloud app testing guide](https://docs.nextcloud.com/server/latest/developer_manual/digging_deeper/testing.html) + ## Release Please (automated versioning & releases) This template includes **[Release Please](https://github.com/googleapis/release-please)** to diff --git a/gen/component/{{pascalCase name}}.test.ts b/gen/component/{{pascalCase name}}.test.ts new file mode 100644 index 0000000..d6c25dd --- /dev/null +++ b/gen/component/{{pascalCase name}}.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Your Name +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Unit tests for {{pascalCase name}} component. + * + * Run tests: + * pnpm test # watch mode + * pnpm test:run # single run + * + * See src/components/StatusBadge.test.ts for a complete example. + */ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import { createIconMock, nextcloudL10nMock } from '@/test-utils' +import {{ pascalCase name }} from './{{pascalCase name}}.vue' + +// ---------------------------------------------------------------------------- +// Mocks - uncomment as needed +// ---------------------------------------------------------------------------- + +// Mock @nextcloud/l10n (if your component uses t() or n()) +// vi.mock('@nextcloud/l10n', () => nextcloudL10nMock) + +// Mock icon components (adjust path and name as needed) +// vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon')) + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +describe('{{pascalCase name}}', () => { + // Example: Basic rendering + // it('renders correctly', () => { + // const wrapper = mount({{pascalCase name}}) + // expect(wrapper.exists()).toBe(true) + // }) + + // Example: Testing with props + // it('renders with props', () => { + // const wrapper = mount({{pascalCase name}}, { + // props: { title: 'Hello' }, + // }) + // expect(wrapper.text()).toContain('Hello') + // }) + + // Example: Testing CSS classes + // it('applies correct CSS class', () => { + // const wrapper = mount({{pascalCase name}}, { + // props: { variant: 'primary' }, + // }) + // expect(wrapper.classes()).toContain('is-primary') + // }) + + // Example: Testing emitted events + // it('emits click event', async () => { + // const wrapper = mount({{pascalCase name}}) + // await wrapper.trigger('click') + // expect(wrapper.emitted('click')).toBeTruthy() + // }) + + // Example: Testing computed properties + // it('computes derived value', () => { + // const wrapper = mount({{pascalCase name}}, { + // props: { count: 5 }, + // }) + // const vm = wrapper.vm as InstanceType + // expect(vm.doubleCount).toBe(10) + // }) + + // Example: Testing conditional rendering + // it('shows content when condition is met', () => { + // const wrapper = mount({{pascalCase name}}, { + // props: { showDetails: true }, + // }) + // expect(wrapper.find('.details').exists()).toBe(true) + // }) + + // Example: Testing slots + // it('renders slot content', () => { + // const wrapper = mount({{pascalCase name}}, { + // slots: { default: 'Slot content' }, + // }) + // expect(wrapper.text()).toContain('Slot content') + // }) + + it.todo('add your tests here') +}) diff --git a/gen/component/{{pascalCase name}}.vue b/gen/component/{{pascalCase name}}.vue index 7ea938c..c5c4b51 100644 --- a/gen/component/{{pascalCase name}}.vue +++ b/gen/component/{{pascalCase name}}.vue @@ -1,10 +1,14 @@ + + + diff --git a/src/test-utils.ts b/src/test-utils.ts new file mode 100644 index 0000000..1483b78 --- /dev/null +++ b/src/test-utils.ts @@ -0,0 +1,60 @@ +/** + * Test utilities and mock factories for Nextcloud dependencies. + * + * Usage in test files: + * + * import { vi } from 'vitest' + * import { nextcloudL10nMock, createIconMock } from '@/test-utils' + * + * vi.mock('@nextcloud/l10n', () => nextcloudL10nMock) + * vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon')) + */ + +/** + * Mock implementation for @nextcloud/l10n. + * + * - `t()` returns the text as-is, with variable substitution for {key} patterns + * - `n()` returns singular or plural based on count + * + * @example + * vi.mock('@nextcloud/l10n', () => nextcloudL10nMock) + */ +export const nextcloudL10nMock = { + t: (_app: string, text: string, vars?: Record) => { + if (vars) { + return Object.entries(vars).reduce( + (acc, [key, value]) => acc.replace(`{${key}}`, String(value)), + text, + ) + } + return text + }, + n: (_app: string, singular: string, plural: string, count: number) => { + return count === 1 ? singular : plural + }, +} + +/** + * Create a mock for an icon component from vue-material-design-icons. + * + * @param name - Component name (e.g., 'CheckIcon') + * @param className - Optional CSS class (defaults to 'mock-{kebab-case-name}') + * @returns Mock factory object for vi.mock() + * + * @example + * vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon')) + * // Creates: + * + * vi.mock('@icons/Alert.vue', () => createIconMock('AlertIcon', 'my-alert')) + * // Creates: + */ +export function createIconMock(name: string, className?: string) { + const cssClass = className ?? `mock-${name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}` + return { + default: { + name, + template: ``, + props: ['size'], + }, + } +} diff --git a/src/utils/string.test.ts b/src/utils/string.test.ts new file mode 100644 index 0000000..ced5ff3 --- /dev/null +++ b/src/utils/string.test.ts @@ -0,0 +1,137 @@ +/** + * Example test file demonstrating pure TypeScript/utility function testing. + * + * This shows how to: + * - Use describe/it/expect structure + * - Test various inputs including edge cases + * - Handle null/undefined values + */ +import { describe, expect, it } from 'vitest' + +import { capitalize, formatFileSize, truncate } from './string' + +describe('truncate', () => { + it('returns empty string for null input', () => { + expect(truncate(null, 10)).toBe('') + }) + + it('returns empty string for undefined input', () => { + expect(truncate(undefined, 10)).toBe('') + }) + + it('returns the original string if shorter than maxLength', () => { + expect(truncate('hello', 10)).toBe('hello') + }) + + it('returns the original string if equal to maxLength', () => { + expect(truncate('hello', 5)).toBe('hello') + }) + + it('truncates and adds ellipsis when string exceeds maxLength', () => { + expect(truncate('hello world', 8)).toBe('hello...') + }) + + it('uses custom ellipsis when provided', () => { + expect(truncate('hello world', 8, '…')).toBe('hello w…') + }) + + it('returns empty string for negative maxLength', () => { + expect(truncate('hello', -1)).toBe('') + }) + + it('handles zero maxLength', () => { + expect(truncate('hello', 0)).toBe('') + }) + + it('handles maxLength smaller than ellipsis', () => { + expect(truncate('hello world', 2)).toBe('..') + }) + + it('handles empty string input', () => { + expect(truncate('', 10)).toBe('') + }) +}) + +describe('capitalize', () => { + it('returns empty string for null input', () => { + expect(capitalize(null)).toBe('') + }) + + it('returns empty string for undefined input', () => { + expect(capitalize(undefined)).toBe('') + }) + + it('returns empty string for empty string input', () => { + expect(capitalize('')).toBe('') + }) + + it('capitalizes a lowercase string', () => { + expect(capitalize('hello')).toBe('Hello') + }) + + it('keeps already capitalized string unchanged', () => { + expect(capitalize('Hello')).toBe('Hello') + }) + + it('capitalizes single character', () => { + expect(capitalize('a')).toBe('A') + }) + + it('handles strings starting with numbers', () => { + expect(capitalize('123abc')).toBe('123abc') + }) + + it('only capitalizes the first character', () => { + expect(capitalize('hELLO')).toBe('HELLO') + }) +}) + +describe('formatFileSize', () => { + it('returns "0 B" for null input', () => { + expect(formatFileSize(null)).toBe('0 B') + }) + + it('returns "0 B" for undefined input', () => { + expect(formatFileSize(undefined)).toBe('0 B') + }) + + it('returns "0 B" for zero bytes', () => { + expect(formatFileSize(0)).toBe('0 B') + }) + + it('returns "0 B" for negative bytes', () => { + expect(formatFileSize(-100)).toBe('0 B') + }) + + it('formats bytes correctly', () => { + expect(formatFileSize(500)).toBe('500 B') + }) + + it('formats kilobytes correctly', () => { + expect(formatFileSize(1024)).toBe('1 KB') + expect(formatFileSize(1536)).toBe('1.5 KB') + }) + + it('formats megabytes correctly', () => { + expect(formatFileSize(1048576)).toBe('1 MB') + expect(formatFileSize(1572864)).toBe('1.5 MB') + }) + + it('formats gigabytes correctly', () => { + expect(formatFileSize(1073741824)).toBe('1 GB') + }) + + it('respects custom decimal places', () => { + expect(formatFileSize(1536, 0)).toBe('2 KB') + expect(formatFileSize(1536, 1)).toBe('1.5 KB') + expect(formatFileSize(1536, 3)).toBe('1.5 KB') + }) + + it('handles Infinity', () => { + expect(formatFileSize(Infinity)).toBe('0 B') + }) + + it('handles NaN', () => { + expect(formatFileSize(NaN)).toBe('0 B') + }) +}) diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 0000000..2c687c7 --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,70 @@ +/** + * Truncates a string to a specified length, adding an ellipsis if truncated. + * + * @param str - The string to truncate + * @param maxLength - Maximum length of the resulting string (including ellipsis) + * @param ellipsis - The ellipsis string to append (default: '...') + * @returns The truncated string + */ +export function truncate( + str: string | null | undefined, + maxLength: number, + ellipsis: string = '...', +): string { + if (str == null) { + return '' + } + + if (maxLength < 0) { + return '' + } + + if (str.length <= maxLength) { + return str + } + + const truncatedLength = maxLength - ellipsis.length + if (truncatedLength <= 0) { + return ellipsis.slice(0, maxLength) + } + + return str.slice(0, truncatedLength) + ellipsis +} + +/** + * Capitalizes the first letter of a string. + * + * @param str - The string to capitalize + * @returns The string with its first letter capitalized + */ +export function capitalize(str: string | null | undefined): string { + if (str == null || str.length === 0) { + return '' + } + + return str.charAt(0).toUpperCase() + str.slice(1) +} + +/** + * Formats a number as a human-readable file size. + * + * @param bytes - The number of bytes + * @param decimals - Number of decimal places (default: 2) + * @returns Formatted string like "1.5 KB" or "2.3 MB" + */ +export function formatFileSize(bytes: number | null | undefined, decimals: number = 2): string { + if (bytes == null || bytes < 0 || !Number.isFinite(bytes)) { + return '0 B' + } + + if (bytes === 0) { + return '0 B' + } + + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + const index = Math.min(i, sizes.length - 1) + + return `${parseFloat((bytes / Math.pow(k, index)).toFixed(decimals))} ${sizes[index]}` +} diff --git a/src/views/AppView.vue b/src/views/AppView.vue index c117b83..18e5326 100644 --- a/src/views/AppView.vue +++ b/src/views/AppView.vue @@ -86,8 +86,9 @@ - - + + + @@ -96,6 +97,12 @@ +
{{ strings.colMessage }}{{ strings.colAt }}{{ strings.colMessage }}{{ strings.colStatus }}{{ strings.colAt }} {{ strings.colActions }}
{{ hello.message }} + + {{ strings.never }} @@ -136,6 +143,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import StatusBadge from '@/components/StatusBadge.vue' import { ocs } from '@/axios' import { t, n } from '@nextcloud/l10n' @@ -149,6 +157,7 @@ export default { NcEmptyContent, NcLoadingIcon, NcDateTime, + StatusBadge, }, data() { return { @@ -205,8 +214,11 @@ export default { emptyDesc: t('nextcloudapptemplate', 'Try adding one using the form above.'), addExample: t('nextcloudapptemplate', 'Add example'), colMessage: t('nextcloudapptemplate', 'Message'), + colStatus: t('nextcloudapptemplate', 'Status'), colAt: t('nextcloudapptemplate', 'Time'), colActions: t('nextcloudapptemplate', 'Actions'), + statusSynced: t('nextcloudapptemplate', 'Synced'), + statusLocal: t('nextcloudapptemplate', 'Local'), duplicate: t('nextcloudapptemplate', 'Duplicate'), remove: t('nextcloudapptemplate', 'Remove'), clearAll: t('nextcloudapptemplate', 'Clear all'), @@ -257,6 +269,7 @@ export default { id: genId(), message: data.message, at: data.at ?? null, + synced: true, }) } } catch (e) { @@ -282,7 +295,7 @@ export default { const data = resp.data const message = data?.message ?? `Hello, ${name}!` const at = data?.at ?? new Date().toISOString() - this.hellos.unshift({ id: genId(), message, at }) + this.hellos.unshift({ id: genId(), message, at, synced: true }) this.clearForm() this.formOpen = false } catch (e) { @@ -295,7 +308,8 @@ export default { duplicate(index) { const src = this.hellos[index] if (!src) return - this.hellos.splice(index + 1, 0, { ...src, id: genId() }) + // Duplicated items are local-only until synced + this.hellos.splice(index + 1, 0, { ...src, id: genId(), synced: false }) }, remove(index) { @@ -307,10 +321,12 @@ export default { }, seedOne() { + // Seeded examples are local-only this.hellos.push({ id: genId(), message: '👋 Hello example', at: new Date().toISOString(), + synced: false, }) }, }, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..bc83cfc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import vue from '@vitejs/plugin-vue' +import path from 'path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + '@icons': path.resolve(__dirname, 'node_modules/vue-material-design-icons'), + }, + }, + test: { + globals: true, + environment: 'happy-dom', + include: ['src/**/*.{test,spec}.{js,ts}'], + }, +})