Testing
Testing Juntos Applications
Write tests in JavaScript for your transpiled Juntos applications using Vitest and in-memory databases.
Table of Contents
- Testing Juntos Applications
Why Test in JavaScript?
Juntos transpiles your Rails code to JavaScript. The dist/ directory contains what actually runs—ES2022 classes, async/await, standard modules. Testing the transpiled output ensures you’re testing what you ship.
| Approach | Pros | Cons |
|---|---|---|
| Ruby tests (Minitest/RSpec) | Familiar Rails patterns | Tests Ruby, not the JS that runs |
| JavaScript tests (Vitest) | Tests actual runtime code | Different syntax |
JavaScript testing also catches transpilation issues—if Ruby2JS produces incorrect JavaScript, your tests will fail.
Setup
Create a test directory with Vitest and better-sqlite3:
mkdir -p test/integration
cd test/integration
npm init -y
npm install --save-dev vitest better-sqlite3
Create vitest.config.mjs:
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
testTimeout: 30000,
},
resolve: {
alias: {
// Point to ruby2js-rails in your built dist
'ruby2js-rails': resolve(__dirname, 'workspace/myapp/dist/node_modules/ruby2js-rails')
}
}
});
Add test scripts to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
Project Structure
test/integration/
├── package.json
├── vitest.config.mjs
├── app.test.mjs # Your tests
└── workspace/
└── myapp/
└── dist/ # Built Juntos app (juntos build -d sqlite -t node)
Build your app for testing:
cd myapp
bin/juntos build -d sqlite -t node
Testing Models
Basic CRUD
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DIST_DIR = join(__dirname, 'workspace/myapp/dist');
let Article, initDatabase, migrations, Application;
describe('Article Model', () => {
beforeAll(async () => {
// Import the database adapter
const activeRecord = await import(join(DIST_DIR, 'lib/active_record.mjs'));
initDatabase = activeRecord.initDatabase;
// Import Application for migrations
const rails = await import(join(DIST_DIR, 'lib/rails.js'));
Application = rails.Application;
// Import migrations
const migrationsModule = await import(join(DIST_DIR, 'db/migrate/index.js'));
migrations = migrationsModule.migrations;
// Import models
const models = await import(join(DIST_DIR, 'app/models/index.js'));
Article = models.Article;
// Configure application
Application.configure({ migrations });
Application.registerModels({ Article });
});
beforeEach(async () => {
// Fresh in-memory database for each test
await initDatabase({ database: ':memory:' });
const adapter = await import(join(DIST_DIR, 'lib/active_record.mjs'));
await Application.runMigrations(adapter);
});
it('creates an article', async () => {
const article = await Article.create({
title: 'Hello World',
body: 'This is my first article.'
});
expect(article.id).toBeDefined();
expect(article.title).toBe('Hello World');
});
it('finds an article by id', async () => {
const created = await Article.create({ title: 'Find Me', body: 'Content' });
const found = await Article.find(created.id);
expect(found.title).toBe('Find Me');
});
it('updates an article', async () => {
const article = await Article.create({ title: 'Original', body: 'Content' });
await article.update({ title: 'Updated' });
const reloaded = await Article.find(article.id);
expect(reloaded.title).toBe('Updated');
});
it('destroys an article', async () => {
const article = await Article.create({ title: 'Delete Me', body: 'Content' });
const id = article.id;
await article.destroy();
const found = await Article.findBy({ id });
expect(found).toBeNull();
});
});
Testing Validations
it('validates title presence', async () => {
const article = new Article({ title: '', body: 'Some content here' });
const saved = await article.save();
expect(saved).toBe(false);
expect(article.errors.title).toBeDefined();
});
it('validates body length', async () => {
const article = new Article({ title: 'Valid Title', body: 'Short' });
const saved = await article.save();
expect(saved).toBe(false);
expect(article.errors.body).toBeDefined();
});
Testing Associations
describe('Associations', () => {
let Comment;
beforeAll(async () => {
const models = await import(join(DIST_DIR, 'app/models/index.js'));
Comment = models.Comment;
Application.registerModels({ Article, Comment });
});
it('article has many comments', async () => {
const article = await Article.create({ title: 'With Comments', body: 'Content here' });
await Comment.create({ article_id: article.id, body: 'First comment' });
await Comment.create({ article_id: article.id, body: 'Second comment' });
const comments = await article.comments;
expect(comments.length).toBe(2);
});
it('comment belongs to article', async () => {
const article = await Article.create({ title: 'Parent', body: 'Content here' });
const comment = await Comment.create({ article_id: article.id, body: 'Child' });
const parent = await comment.article;
expect(parent.id).toBe(article.id);
});
it('destroys dependent comments', async () => {
const article = await Article.create({ title: 'Cascade', body: 'Content here' });
await Comment.create({ article_id: article.id, body: 'Will be deleted' });
await article.destroy();
const orphans = await Comment.where({ article_id: article.id });
expect(orphans.length).toBe(0);
});
});
Testing Query Interface
describe('Query Interface', () => {
beforeEach(async () => {
await Article.create({ title: 'Alpha', body: 'First article content' });
await Article.create({ title: 'Beta', body: 'Second article content' });
await Article.create({ title: 'Gamma', body: 'Third article content' });
});
it('where filters by attributes', async () => {
const results = await Article.where({ title: 'Beta' });
expect(results.length).toBe(1);
expect(results[0].title).toBe('Beta');
});
it('order sorts results', async () => {
const results = await Article.order({ title: 'desc' });
expect(results[0].title).toBe('Gamma');
expect(results[2].title).toBe('Alpha');
});
it('limit restricts count', async () => {
const results = await Article.limit(2);
expect(results.length).toBe(2);
});
it('first returns single record', async () => {
const first = await Article.first();
expect(first).toBeDefined();
expect(first.id).toBe(1);
});
it('count returns total', async () => {
const count = await Article.count();
expect(count).toBe(3);
});
it('findBy returns matching record', async () => {
const article = await Article.findBy({ title: 'Beta' });
expect(article.title).toBe('Beta');
});
it('chains where, order, limit', async () => {
await Article.create({ title: 'Alpha 2', body: 'Another alpha article' });
const results = await Article.where({ title: 'Alpha' })
.order({ id: 'desc' })
.limit(1);
expect(results.length).toBe(1);
});
});
Testing Controllers
Controllers need a mock context object simulating the request environment:
describe('ArticlesController', () => {
let ArticlesController;
beforeAll(async () => {
const ctrl = await import(join(DIST_DIR, 'app/controllers/articles_controller.js'));
ArticlesController = ctrl.ArticlesController;
});
it('index returns article list', async () => {
await Article.create({ title: 'Listed', body: 'Content for listing' });
const context = {
params: {},
flash: {
get: () => '',
consumeNotice: () => '',
consumeAlert: () => ''
},
contentFor: {}
};
const html = await ArticlesController.index(context);
expect(html).toContain('Listed');
});
it('create adds new article', async () => {
const context = {
params: {},
flash: { set: () => {} },
contentFor: {},
request: { headers: { accept: 'text/html' } }
};
const params = {
title: 'New Article',
body: 'Created via controller test'
};
await ArticlesController.create(context, params);
const articles = await Article.all();
expect(articles.length).toBe(1);
expect(articles[0].title).toBe('New Article');
});
it('show displays single article', async () => {
const article = await Article.create({ title: 'Show Me', body: 'Detailed content' });
const context = {
params: { id: article.id },
flash: {
get: () => '',
consumeNotice: () => '',
consumeAlert: () => ''
},
contentFor: {}
};
const html = await ArticlesController.show(context);
expect(html).toContain('Show Me');
expect(html).toContain('Detailed content');
});
});
Testing Turbo Stream Responses
For controllers that return Turbo Streams:
it('create returns turbo stream for turbo requests', async () => {
const context = {
params: {},
flash: { set: () => {} },
contentFor: {},
request: {
headers: { accept: 'text/vnd.turbo-stream.html' }
}
};
const result = await ArticlesController.create(context, {
title: 'Turbo Article',
body: 'Content for turbo stream'
});
expect(result.turbo_stream).toBeDefined();
expect(result.turbo_stream).toContain('turbo-stream');
});
Testing Path Helpers
describe('Path Helpers', () => {
let articles_path, article_path, edit_article_path;
beforeAll(async () => {
const paths = await import(join(DIST_DIR, 'config/paths.js'));
articles_path = paths.articles_path;
article_path = paths.article_path;
edit_article_path = paths.edit_article_path;
});
it('articles_path returns index path', () => {
expect(articles_path()).toBe('/articles');
});
it('article_path returns show path', () => {
expect(article_path(42)).toBe('/articles/42');
expect(article_path({ id: 42 })).toBe('/articles/42');
});
it('edit_article_path returns edit path', () => {
expect(edit_article_path(42)).toBe('/articles/42/edit');
});
it('nested paths work correctly', async () => {
const paths = await import(join(DIST_DIR, 'config/paths.js'));
const { article_comments_path } = paths;
expect(article_comments_path(1)).toBe('/articles/1/comments');
});
});
In-Memory Database Pattern
The key pattern for fast, isolated tests:
beforeEach(async () => {
// Create fresh database for each test
await initDatabase({ database: ':memory:' });
// Re-import adapter to get fresh connection
const adapter = await import(join(DIST_DIR, 'lib/active_record.mjs'));
// Run migrations
await Application.runMigrations(adapter);
});
Each test gets a clean slate. No cleanup needed. Tests can run in parallel without interference.
Testing React Components
Applications using RBX files with React (like the workflow demo) need jsdom for DOM simulation:
npm install --save-dev jsdom
Update vitest.config.mjs:
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
testTimeout: 30000,
environment: 'jsdom', // Enable DOM simulation
css: false, // Mock CSS imports
},
resolve: {
alias: {
'ruby2js-rails': resolve(__dirname, 'workspace/myapp/dist/node_modules/ruby2js-rails'),
// Map absolute imports used by React components
'/lib/': resolve(__dirname, 'workspace/myapp/dist/lib') + '/',
'/app/': resolve(__dirname, 'workspace/myapp/dist/app') + '/',
}
}
});
The /lib/ and /app/ aliases resolve absolute imports like import JsonStreamProvider from '/lib/JsonStreamProvider.js' that React components use.
Testing Controllers with React Views
Controllers that return React component output work the same way:
describe('WorkflowsController', () => {
it('show renders workflow canvas', async () => {
const workflow = await Workflow.create({ name: 'Test Flow' });
const context = {
params: { id: workflow.id },
flash: { get: () => '', consumeNotice: () => '', consumeAlert: () => '' },
contentFor: {}
};
const result = await WorkflowsController.show(context, workflow.id);
// React components return rendered output
expect(result).toBeDefined();
});
});
Running Tests
# Run all tests
npm test
# Run specific test file
npm test -- articles.test.mjs
# Watch mode during development
npm run test:watch
# Run with verbose output
npm test -- --reporter=verbose
Debugging Tests
When a test fails, check:
- Import paths — Ensure DIST_DIR points to your built app
- Missing migrations — Run
bin/juntos buildto regeneratedist/ - Context mocks — Controllers expect specific context properties
- Async/await — Model methods are async; don’t forget
await
Add console logging to debug:
it('debug example', async () => {
const article = await Article.create({ title: 'Debug', body: 'Content' });
console.log('Created:', article);
console.log('Errors:', article.errors);
const all = await Article.all();
console.log('All articles:', all);
});
Next Steps
- See Active Record for the full query interface, validations, and callbacks
- See the Architecture to understand what gets generated
- Check Demo Applications for complete test examples
- Review the Ruby2JS integration tests for real-world patterns