Testing Strategy - smartlabsAT/directus-expandable-blocks GitHub Wiki

Testing Strategy

Comprehensive testing guide for the Expandable Blocks extension.

๐Ÿงช Testing Overview

Test Structure

test/
โ”œโ”€โ”€ unit/                    # Unit tests
โ”‚   โ”œโ”€โ”€ composables/        # Composable tests
โ”‚   โ”œโ”€โ”€ utils/              # Utility tests
โ”‚   โ”œโ”€โ”€ components/         # Component tests
โ”‚   โ””โ”€โ”€ api/                # API service tests
โ”œโ”€โ”€ mocks/                  # Mocked dependencies
โ””โ”€โ”€ setup.ts               # Test configuration

Note: E2E tests have been removed from the project. Focus is on comprehensive unit testing.

๐Ÿ“ฆ Unit Testing

Setup

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./test/setup.ts']
  }
});

Testing Composables

// useExpandableBlocks.test.ts
import { describe, it, expect, vi } from 'vitest';
import { useExpandableBlocks } from '@/composables/useExpandableBlocks';

describe('useExpandableBlocks', () => {
  it('should initialize with empty blocks', () => {
    const props = { value: null };
    const emit = vi.fn();
    
    const { items } = useExpandableBlocks(props, emit);
    
    expect(items.value).toEqual([]);
  });
  
  it('should detect dirty state on content change', () => {
    const props = { value: [/* test data */] };
    const emit = vi.fn();
    
    const { isDirty, updateBlock } = useExpandableBlocks(props, emit);
    
    expect(isDirty.value).toBe(false);
    
    updateBlock('1', { title: 'New Title' });
    
    expect(isDirty.value).toBe(true);
    expect(emit).toHaveBeenCalled();
  });
});

Testing Utilities

// m2a-helper.test.ts
describe('M2A Helper', () => {
  it('should extract M2A structure correctly', () => {
    const relationInfo = {
      meta: {
        one_collection_field: 'collection',
        one_deselect_action: 'nullify'
      }
    };
    
    const result = extractM2AStructure('pages', 'blocks', relationInfo);
    
    expect(result).toEqual({
      junctionCollection: 'pages_blocks',
      junctionField: 'pages_id',
      collectionField: 'collection',
      itemField: 'blocks_id'
    });
  });
  
  it('should handle missing relations gracefully', () => {
    const result = extractM2AStructure('pages', 'blocks', null);
    
    expect(result).toBeNull();
  });
});

Component Testing

// NestedBlocks.test.ts
import { mount } from '@vue/test-utils';
import NestedBlocks from '@/components/NestedBlocks.vue';

describe('NestedBlocks', () => {
  it('renders blocks correctly', async () => {
    const wrapper = mount(NestedBlocks, {
      props: {
        items: [
          { id: 1, collection: 'text', item: { title: 'Test' } }
        ]
      }
    });
    
    expect(wrapper.find('.nested-block').exists()).toBe(true);
    expect(wrapper.text()).toContain('Test');
  });
  
  it('handles expansion correctly', async () => {
    const wrapper = mount(NestedBlocks, {
      props: {
        items: [/* test data */],
        startExpanded: false
      }
    });
    
    expect(wrapper.find('.block-content').exists()).toBe(false);
    
    await wrapper.find('.expand-button').trigger('click');
    
    expect(wrapper.find('.block-content').exists()).toBe(true);
  });
});

๐Ÿงช New Component Tests

Testing Item Selector

// ItemSelectorDrawer.test.ts
import { mount } from '@vue/test-utils';
import ItemSelectorDrawer from '@/components/ItemSelectorDrawer.vue';

describe('ItemSelectorDrawer', () => {
  it('should search items correctly', async () => {
    const wrapper = mount(ItemSelectorDrawer, {
      props: {
        modelValue: true,
        collection: 'content_text',
        allowedCollections: ['content_text', 'content_image']
      }
    });
    
    await wrapper.find('input[type="search"]').setValue('test');
    await wrapper.vm.$nextTick();
    
    expect(wrapper.emitted('search')).toBeTruthy();
  });
  
  it('should toggle between views', async () => {
    const wrapper = mount(ItemSelectorDrawer, {
      props: { /* ... */ }
    });
    
    await wrapper.find('[data-test="view-table"]').trigger('click');
    
    expect(wrapper.vm.viewMode).toBe('table');
    expect(localStorage.getItem('itemSelectorSettings')).toContain('table');
  });
});

Testing Search Functionality

// SearchTagInput.test.ts
describe('SearchTagInput', () => {
  it('should parse field-specific searches', () => {
    const wrapper = mount(SearchTagInput, {
      props: {
        modelValue: 'title:"Product Launch" status:published'
      }
    });
    
    expect(wrapper.vm.filters).toEqual([
      { field: 'title', value: 'Product Launch', operator: 'equals' },
      { field: 'status', value: 'published', operator: 'equals' }
    ]);
  });
  
  it('should emit filter changes', async () => {
    const wrapper = mount(SearchTagInput);
    
    await wrapper.vm.addFilter('description', 'test');
    
    expect(wrapper.emitted('update:modelValue')[0]).toEqual(['description:"test"']);
  });
});

Testing API Services

// ItemLoader.test.ts
import { ItemLoader } from '@/api/services/ItemLoader';

describe('ItemLoader', () => {
  it('should load items with relations', async () => {
    const mockDatabase = createMockDatabase();
    const mockSchema = createMockSchema();
    
    const loader = new ItemLoader(mockDatabase, mockSchema);
    const result = await loader.loadItems('content_text', {
      limit: 10,
      search: 'test'
    });
    
    expect(result.items).toHaveLength(10);
    expect(result.items[0]).toHaveProperty('title');
  });
  
  it('should respect permissions', async () => {
    const loader = new ItemLoader(mockDatabase, mockSchema);
    loader.setAccountability({ role: 'editor' });
    
    const result = await loader.loadItems('restricted_content');
    
    expect(result.items).toHaveLength(0);
  });
});

๐ŸŽฏ Key Testing Scenarios

1. State Management

describe('State Management', () => {
  test('dirty state detection', () => {
    // Test content changes
    // Test position changes
    // Test combined changes
  });
  
  test('save and reset', () => {
    // Test save detection
    // Test state reset after save
    // Test original state updates
  });
});

2. Data Integrity

describe('Data Integrity', () => {
  test('maintains junction IDs', () => {
    // Verify IDs preserved
    // Check foreign keys
  });
  
  test('handles missing data', () => {
    // Test null items
    // Test invalid collections
  });
});

3. Performance

describe('Performance', () => {
  test('handles large datasets', () => {
    const largeDataset = generateBlocks(1000);
    // Test render time
    // Test memory usage
  });
  
  test('efficient updates', () => {
    // Test selective emitting
    // Test minimal re-renders
  });
});

๐Ÿ” Debugging Tests

Using Vitest UI

# Run tests with UI interface
npm run test:ui

# Debug specific test
npm run test -- ItemSelectorDrawer.test.ts

Console Logging in Tests

// Use the logger wrapper in tests
import { logDebug } from '@/utils/logger-wrapper';

describe('Component', () => {
  it('should work', () => {
    logDebug('Test data', { items });
    // Test logic
  });
});

Mock Debugging

// Inspect mock calls
const mockFn = vi.fn();
// After test execution
console.log('Mock calls:', mockFn.mock.calls);
console.log('Mock results:', mockFn.mock.results);

๐Ÿ“Š Coverage Goals

Target Coverage

  • Statements: > 80%
  • Branches: > 75%
  • Functions: > 80%
  • Lines: > 80%

Critical Paths

Must have 100% coverage:

  • Dirty state detection
  • Save/discard logic
  • Permission checks
  • Data transformation

Run Coverage

npm run test:coverage

# Generate HTML report
npm run test:coverage -- --reporter=html

๐Ÿ† Best Practices

1. Test Organization

// Group related tests
describe('Feature: Drag and Drop', () => {
  describe('when sorting enabled', () => {
    // Tests
  });
  
  describe('when sorting disabled', () => {
    // Tests
  });
});

2. Test Data

// Use factories
function createBlock(overrides = {}) {
  return {
    id: 1,
    collection: 'content_text',
    item: { title: 'Test' },
    sort: 0,
    ...overrides
  };
}

3. Async Testing

// Always await async operations
test('async operations', async () => {
  const result = await loadBlockData('123');
  expect(result).toBeDefined();
});

4. Cleanup

// Clean up after tests
afterEach(() => {
  vi.clearAllMocks();
  localStorage.clear();
});

Related: [Development]] ](/smartlabsAT/directus-expandable-blocks/wiki/[[Contributing)