Skip to content

test(vue): Add tests for Vue tracing mixins #16486

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 5, 2025
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions packages/vue/test/tracing/tracingMixin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { getActiveSpan, startInactiveSpan } from '@sentry/browser';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_HOOKS } from '../../src/constants';
import { createTracingMixins } from '../../src/tracing';

vi.mock('@sentry/browser', () => {
return {
getActiveSpan: vi.fn(),
startInactiveSpan: vi.fn().mockImplementation(({ name, op }) => {
return {
end: vi.fn(),
startChild: vi.fn(),
name,
op,
};
}),
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin',
};
});

vi.mock('../../src/vendor/components', () => {
return {
formatComponentName: vi.fn().mockImplementation(vm => {
return vm.componentName || 'TestComponent';
}),
};
});

const mockSpanFactory = (): { name?: string; op?: string; end: Mock; startChild: Mock } => ({
name: undefined,
op: undefined,
end: vi.fn(),
startChild: vi.fn(),
});

vi.useFakeTimers();

describe('Vue Tracing Mixins', () => {
let mockVueInstance: any;
let mockRootInstance: any;

beforeEach(() => {
vi.clearAllMocks();

mockRootInstance = {
$root: null,
componentName: 'RootComponent',
$_sentrySpans: {},
};
mockRootInstance.$root = mockRootInstance; // Self-reference for root

mockVueInstance = {
$root: mockRootInstance,
componentName: 'TestComponent',
$_sentrySpans: {},
};

(getActiveSpan as any).mockReturnValue({ id: 'parent-span' });
(startInactiveSpan as any).mockImplementation(({ name, op }: { name: string; op: string }) => {
const newSpan = mockSpanFactory();
newSpan.name = name;
newSpan.op = op;
return newSpan;
});
});

afterEach(() => {
vi.clearAllTimers();
});

describe('Mixin Creation', () => {
it('should create mixins for default hooks', () => {
const mixins = createTracingMixins();

DEFAULT_HOOKS.forEach(hook => {
const hookPairs = {
mount: ['beforeMount', 'mounted'],
update: ['beforeUpdate', 'updated'],
destroy: ['beforeDestroy', 'destroyed'],
unmount: ['beforeUnmount', 'unmounted'],
create: ['beforeCreate', 'created'],
activate: ['activated', 'deactivated'],
};

if (hook in hookPairs) {
hookPairs[hook as keyof typeof hookPairs].forEach(lifecycleHook => {
expect(mixins).toHaveProperty(lifecycleHook);
// @ts-expect-error we check the type here
expect(typeof mixins[lifecycleHook]).toBe('function');
});
}
});
});

it('should always include the activate and mount hooks', () => {
const mixins = createTracingMixins({ hooks: undefined });

expect(Object.keys(mixins)).toEqual(['activated', 'deactivated', 'beforeMount', 'mounted']);
});

it('should create mixins for custom hooks', () => {
const mixins = createTracingMixins({ hooks: ['update'] });

expect(Object.keys(mixins)).toEqual([
'beforeUpdate',
'updated',
'activated',
'deactivated',
'beforeMount',
'mounted',
]);
});
});

describe('Root Component Behavior', () => {
it('should always create root span for root component regardless of tracking options', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Just to avoid confusion: We're talking about a span for the root component, right? Not to confuse with a root (i.e. parentless) span?

Suggested change
it('should always create root span for root component regardless of tracking options', () => {
it('should always create root component span for root component regardless of tracking options', () => {

const mixins = createTracingMixins({ trackComponents: false });

mixins.beforeMount.call(mockRootInstance);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q (for my understanding mostly): What happens if I call something like beforeCreate or another hook coming in prior to the mount hook? the span would end if the -ed hook emits, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each operation (create, mount, ...) has its own independent span that is stored in the component's $_sentrySpans object. And only if this operation is stored in there (like create) it will end only if the created is emitted and the create span is available in the $_sentrySpans object.


expect(startInactiveSpan).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Application Render',
op: 'ui.vue.render',
}),
);
});

it('should finish root span on timer after component spans end', () => {
// todo/fixme: This root span is only finished if trackComponents is true --> it should probably be always finished
Copy link
Member Author

@s1gr1d s1gr1d Jun 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe someone knows more here about the wanted behavior 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm this looks like a bug then? according to this code trackComponents shouldn't influence the root component span. Feel free to fix directly or follow up with another PR. Whatever you prefer :) (let me know if I should fix it in case you're booked, also fine!)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's always starting the span, but not ending it

const mixins = createTracingMixins({ trackComponents: true, timeout: 1000 });
const rootMockSpan = mockSpanFactory();
mockRootInstance.$_sentryRootSpan = rootMockSpan;

// Create and finish a component span
mixins.beforeMount.call(mockVueInstance);
mixins.mounted.call(mockVueInstance);

// Root span should not end immediately
expect(rootMockSpan.end).not.toHaveBeenCalled();

// After timeout, root span should end
vi.advanceTimersByTime(1001);
expect(rootMockSpan.end).toHaveBeenCalled();
});
});

describe('Component Span Lifecycle', () => {
it('should create and end spans correctly through lifecycle hooks', () => {
const mixins = createTracingMixins({ trackComponents: true });

// 1. Create span in "before" hook
mixins.beforeMount.call(mockVueInstance);

// Verify span was created with correct details
expect(startInactiveSpan).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Vue TestComponent',
op: 'ui.vue.mount',
}),
);
expect(mockVueInstance.$_sentrySpans.mount).toBeDefined();

// 2. Get the span for verification
const componentSpan = mockVueInstance.$_sentrySpans.mount;

// 3. End span in "after" hook
mixins.mounted.call(mockVueInstance);
expect(componentSpan.end).toHaveBeenCalled();
});

it('should clean up existing spans when creating new ones', () => {
const mixins = createTracingMixins({ trackComponents: true });

// Create an existing span first
const oldSpan = mockSpanFactory();
mockVueInstance.$_sentrySpans.mount = oldSpan;

// Create a new span for the same operation
mixins.beforeMount.call(mockVueInstance);

// Verify old span was ended and new span was created
expect(oldSpan.end).toHaveBeenCalled();
expect(mockVueInstance.$_sentrySpans.mount).not.toBe(oldSpan);
});

it('should gracefully handle when "after" hook is called without "before" hook', () => {
const mixins = createTracingMixins();

// Call mounted hook without calling beforeMount first
expect(() => mixins.mounted.call(mockVueInstance)).not.toThrow();
});

it('should skip spans when no active root span (transaction) exists', () => {
const mixins = createTracingMixins({ trackComponents: true });

// Remove active spans
(getActiveSpan as any).mockReturnValue(null);
mockRootInstance.$_sentryRootSpan = null;

// Try to create a span
mixins.beforeMount.call(mockVueInstance);

// No span should be created
expect(startInactiveSpan).not.toHaveBeenCalled();
});
});

describe('Component Tracking Options', () => {
it('should respect tracking configuration options', () => {
// Test different tracking configurations with the same component
const runTracingTest = (trackComponents: boolean | string[] | undefined, shouldTrack: boolean) => {
vi.clearAllMocks();
const mixins = createTracingMixins({ trackComponents });
mixins.beforeMount.call(mockVueInstance);

if (shouldTrack) {
expect(startInactiveSpan).toHaveBeenCalled();
} else {
expect(startInactiveSpan).not.toHaveBeenCalled();
}
};
Copy link
Member

@Lms24 Lms24 Jun 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: It feels like we're running multiple scenarios within one test here which I think we can better illustrate by using it.each and/or splitting tests up a bit. For example, two scenarios should lead to a span being started, while two should not. Those are already two different branches.

The two advantages of running individual tests here are:

  • we can directly identify which scenario passes/fails
  • we can avoid the shouldTrack branching in the test all together. IMHO unit tests should almost never contain conditional assertions as this usually indicates testing different expected outcomes in one test. (Sometimes there's an argument for this in case there'd be a lot of code to repeat but in this case I think we can avoid repetition pretty well)

happy to let you make the call on this, so feel free to disregard. Also maybe I missed a reason why the current form makes more sense.


// Test all tracking configurations
runTracingTest(undefined, false); // Default - don't track
runTracingTest(false, false); // Explicitly disabled
runTracingTest(true, true); // Track all components
runTracingTest(['TestComponent'], true); // Track by name (match)

// Test component not in tracking list
vi.clearAllMocks();
const mixins = createTracingMixins({ trackComponents: ['OtherComponent'] });
mixins.beforeMount.call(mockVueInstance); // TestComponent
expect(startInactiveSpan).not.toHaveBeenCalled();
});
});
});
Loading