From ae0165f1fe7dba9dd8ddaa1afa722a939772d3b6 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Tue, 23 Jul 2024 16:08:04 +0200 Subject: [PATCH] Ensure tests do not log warning or errors This commit increases strictnes of tests by failing on tests (even though they pass) if `console.warn` or `console.error` is used. This is used to fix warning outputs from Vue, cleaning up test output and preventing potential issues with tests. This commit fixes all of the failing tests, including refactoring in code to make them more testable through injecting Vue lifecycle hook function stubs. This removes `shallowMount`ing done on places, improving the speed of executing unit tests. It also reduces complexity and increases maintainability by removing `@vue/test-utils` dependency for these tests. Changes: - Register global hook for all tests to fail if console.error or console.warn is being used. - Fix all issues with failing tests. - Create test helper function for running code in a wrapper component to run code in reliable/unified way to surpress Vue warnings about code not running inside `setup`. --- .../Scripts/Slider/UseDragHandler.ts | 4 +- .../Scripts/Slider/UseGlobalCursor.ts | 4 +- .../Shared/Hooks/Common/LifecycleHook.ts | 8 ++ .../Shared/Hooks/Resize/UseResizeObserver.ts | 7 +- .../Hooks/UseAutoUnsubscribedEventListener.ts | 13 ++- .../Shared/Hooks/UseAutoUnsubscribedEvents.ts | 4 +- .../composite/DependencyResolution.spec.ts | 17 ++-- .../Tree/Shared/Icon/UseSvgLoader.spec.ts | 2 +- .../Node/UseKeyboardInteractionState.spec.ts | 11 ++- .../Vue/ExecuteInComponentSetupContext.ts | 27 ++++++ tests/shared/{ => Vue}/WaitForValueChange.ts | 0 .../bootstrap/FailTestOnConsoleError.ts | 23 +++++ tests/shared/bootstrap/setup.ts | 2 + .../bootstrapping/DependencyProvider.spec.ts | 13 ++- .../Rating/CircleRating.spec.ts | 33 +++---- .../Scripts/Slider/UseDragHandler.spec.ts | 78 ++++++++++------ .../Scripts/Slider/UseGlobalCursor.spec.ts | 56 +++++------- .../UseNodeStateChangeAggregator.spec.ts | 14 +-- .../UseAutoUnsubscribedEventListener.spec.ts | 89 ++++++++++--------- .../Hooks/UseAutoUnsubscribedEvents.spec.ts | 64 ++++++++----- .../Shared/Icon/UseSvgLoader.spec.ts | 2 +- .../UseLockBodyBackgroundScroll.spec.ts | 28 +++--- .../Shared/Modal/ModalContainer.spec.ts | 2 +- .../Shared/Resize/UseResizeObserver.spec.ts | 26 +++--- tests/unit/shared/Stubs/LifecycleHookStub.ts | 31 +++++++ .../Stubs/VueDependencyInjectionApiStub.ts | 6 +- 26 files changed, 359 insertions(+), 205 deletions(-) create mode 100644 src/presentation/components/Shared/Hooks/Common/LifecycleHook.ts create mode 100644 tests/shared/Vue/ExecuteInComponentSetupContext.ts rename tests/shared/{ => Vue}/WaitForValueChange.ts (100%) create mode 100644 tests/shared/bootstrap/FailTestOnConsoleError.ts create mode 100644 tests/unit/shared/Stubs/LifecycleHookStub.ts diff --git a/src/presentation/components/Scripts/Slider/UseDragHandler.ts b/src/presentation/components/Scripts/Slider/UseDragHandler.ts index 1b813894..8d900072 100644 --- a/src/presentation/components/Scripts/Slider/UseDragHandler.ts +++ b/src/presentation/components/Scripts/Slider/UseDragHandler.ts @@ -3,6 +3,7 @@ import { } from 'vue'; import { throttle } from '@/application/Common/Timing/Throttle'; import type { Ref } from 'vue'; +import type { LifecycleHook } from '../../Shared/Hooks/Common/LifecycleHook'; const ThrottleInMs = 15; @@ -10,6 +11,7 @@ export function useDragHandler( draggableElementRef: Readonly>, dragDomModifier: DragDomModifier = new GlobalDocumentDragDomModifier(), throttler = throttle, + onTeardown: LifecycleHook = onUnmounted, ) { const displacementX = ref(0); const isDragging = ref(false); @@ -52,7 +54,7 @@ export function useDragHandler( element.addEventListener('pointerdown', startDrag); } - onUnmounted(() => { + onTeardown(() => { stopDrag(); }); diff --git a/src/presentation/components/Scripts/Slider/UseGlobalCursor.ts b/src/presentation/components/Scripts/Slider/UseGlobalCursor.ts index dafd7546..d8dbb9c2 100644 --- a/src/presentation/components/Scripts/Slider/UseGlobalCursor.ts +++ b/src/presentation/components/Scripts/Slider/UseGlobalCursor.ts @@ -1,9 +1,11 @@ import { watch, type Ref, onUnmounted } from 'vue'; +import type { LifecycleHook } from '../../Shared/Hooks/Common/LifecycleHook'; export function useGlobalCursor( isActive: Readonly>, cursorCssValue: string, documentAccessor: CursorStyleDomModifier = new GlobalDocumentCursorStyleDomModifier(), + onTeardown: LifecycleHook = onUnmounted, ) { const cursorStyle = createCursorStyle(cursorCssValue, documentAccessor); @@ -15,7 +17,7 @@ export function useGlobalCursor( } }); - onUnmounted(() => { + onTeardown(() => { documentAccessor.removeElement(cursorStyle); }); } diff --git a/src/presentation/components/Shared/Hooks/Common/LifecycleHook.ts b/src/presentation/components/Shared/Hooks/Common/LifecycleHook.ts new file mode 100644 index 00000000..fb250344 --- /dev/null +++ b/src/presentation/components/Shared/Hooks/Common/LifecycleHook.ts @@ -0,0 +1,8 @@ +/* + These types are used to abstract Vue Lifecycle injection APIs + (e.g., onBeforeMount, onUnmount) for better testability. +*/ + +export type LifecycleHook = (callback: LifecycleHookCallback) => void; + +export type LifecycleHookCallback = () => void; diff --git a/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts b/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts index 0ee22f8b..be7e5ac1 100644 --- a/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts +++ b/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts @@ -5,14 +5,15 @@ import { import { throttle, type ThrottleFunction } from '@/application/Common/Timing/Throttle'; import { useResizeObserverPolyfill } from './UseResizeObserverPolyfill'; import { useAnimationFrameLimiter } from './UseAnimationFrameLimiter'; +import type { LifecycleHook } from '../Common/LifecycleHook'; export function useResizeObserver( config: ResizeObserverConfig, usePolyfill = useResizeObserverPolyfill, useFrameLimiter = useAnimationFrameLimiter, throttler: ThrottleFunction = throttle, - onSetup: LifecycleHookRegistration = onBeforeMount, - onTeardown: LifecycleHookRegistration = onBeforeUnmount, + onSetup: LifecycleHook = onBeforeMount, + onTeardown: LifecycleHook = onBeforeUnmount, ) { const { resetNextFrame, cancelNextFrame } = useFrameLimiter(); // This prevents the 'ResizeObserver loop completed with undelivered notifications' error when @@ -63,5 +64,3 @@ export interface ResizeObserverConfig { } export type ObservedElementReference = Readonly>; - -export type LifecycleHookRegistration = (callback: () => void) => void; diff --git a/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.ts b/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.ts index 5db50ce0..4ce57125 100644 --- a/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.ts +++ b/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.ts @@ -4,12 +4,17 @@ import { watch, type Ref, } from 'vue'; +import type { LifecycleHook } from './Common/LifecycleHook'; export interface UseEventListener { - (): TargetEventListener; + ( + onTeardown?: LifecycleHook, + ): TargetEventListener; } -export const useAutoUnsubscribedEventListener: UseEventListener = () => ({ +export const useAutoUnsubscribedEventListener: UseEventListener = ( + onTeardown = onBeforeUnmount, +) => ({ startListening: (eventTargetSource, eventType, eventHandler) => { const eventTargetRef = isEventTarget(eventTargetSource) ? shallowRef(eventTargetSource) @@ -18,6 +23,7 @@ export const useAutoUnsubscribedEventListener: UseEventListener = () => ({ eventTargetRef, eventType, eventHandler, + onTeardown, ); }, }); @@ -42,6 +48,7 @@ function startListeningRef( eventTargetRef: Readonly>, eventType: TEvent, eventHandler: (event: HTMLElementEventMap[TEvent]) => void, + onTeardown: LifecycleHook, ): void { const eventListenerManager = new EventListenerManager(); watch(() => eventTargetRef.value, (element) => { @@ -52,7 +59,7 @@ function startListeningRef( eventListenerManager.addListener(element, eventType, eventHandler); }, { immediate: true }); - onBeforeUnmount(() => { + onTeardown(() => { eventListenerManager.removeListenerIfExists(); }); } diff --git a/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.ts b/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.ts index 59b46961..c2bb6ea0 100644 --- a/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.ts +++ b/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.ts @@ -1,15 +1,17 @@ import { onUnmounted } from 'vue'; import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection'; import type { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection'; +import type { LifecycleHook } from './Common/LifecycleHook'; export function useAutoUnsubscribedEvents( events: IEventSubscriptionCollection = new EventSubscriptionCollection(), + onTeardown: LifecycleHook = onUnmounted, ) { if (events.subscriptionCount > 0) { throw new Error('there are existing subscriptions, this may lead to side-effects'); } - onUnmounted(() => { + onTeardown(() => { events.unsubscribeAll(); }); diff --git a/tests/integration/composite/DependencyResolution.spec.ts b/tests/integration/composite/DependencyResolution.spec.ts index 68d7161c..d3033fdf 100644 --- a/tests/integration/composite/DependencyResolution.spec.ts +++ b/tests/integration/composite/DependencyResolution.spec.ts @@ -1,10 +1,10 @@ import { it, describe, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; -import { defineComponent, inject } from 'vue'; +import { inject } from 'vue'; import { type InjectionKeySelector, InjectionKeys, injectKey } from '@/presentation/injectionSymbols'; import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider'; import { buildContext } from '@/application/Context/ApplicationContextFactory'; import type { IApplicationContext } from '@/application/Context/IApplicationContext'; +import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext'; describe('DependencyResolution', () => { describe('all dependencies can be injected', async () => { @@ -16,7 +16,7 @@ describe('DependencyResolution', () => { // act const resolvedDependency = resolve(() => key, dependencies); // assert - expect(resolvedDependency).to.toBeDefined(); + expect(resolvedDependency).toBeDefined(); }); }); }); @@ -40,13 +40,14 @@ function resolve( providedKeys: ProvidedKeys, ): T | undefined { let injectedDependency: T | undefined; - shallowMount(defineComponent({ - setup() { + executeInComponentSetupContext({ + setupCallback: () => { injectedDependency = injectKey(selector); }, - }), { - global: { - provide: providedKeys, + mountOptions: { + global: { + provide: providedKeys, + }, }, }); return injectedDependency; diff --git a/tests/integration/presentation/components/Scripts/View/Tree/Shared/Icon/UseSvgLoader.spec.ts b/tests/integration/presentation/components/Scripts/View/Tree/Shared/Icon/UseSvgLoader.spec.ts index 992d909e..21e10a2b 100644 --- a/tests/integration/presentation/components/Scripts/View/Tree/Shared/Icon/UseSvgLoader.spec.ts +++ b/tests/integration/presentation/components/Scripts/View/Tree/Shared/Icon/UseSvgLoader.spec.ts @@ -3,7 +3,7 @@ import { } from 'vitest'; import { IconNames } from '@/presentation/components/Shared/Icon/IconName'; import { useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader'; -import { waitForValueChange } from '@tests/shared/WaitForValueChange'; +import { waitForValueChange } from '@tests/shared/Vue/WaitForValueChange'; describe('useSvgLoader', () => { describe('can load all SVGs', () => { diff --git a/tests/integration/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts b/tests/integration/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts index 956ed9c3..5bab859c 100644 --- a/tests/integration/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts +++ b/tests/integration/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts @@ -1,8 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; -import { defineComponent } from 'vue'; import { useKeyboardInteractionState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState'; import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext'; describe('useKeyboardInteractionState', () => { describe('isKeyboardBeingUsed', () => { @@ -49,12 +48,12 @@ function triggerKeyPress() { function mountWrapperComponent() { let returnObject: ReturnType | undefined; - const wrapper = shallowMount(defineComponent({ - setup() { + const wrapper = executeInComponentSetupContext({ + setupCallback: () => { returnObject = useKeyboardInteractionState(); }, - template: '
', - })); + disableAutoUnmount: true, + }); expectExists(returnObject); return { returnObject, diff --git a/tests/shared/Vue/ExecuteInComponentSetupContext.ts b/tests/shared/Vue/ExecuteInComponentSetupContext.ts new file mode 100644 index 00000000..532d28f2 --- /dev/null +++ b/tests/shared/Vue/ExecuteInComponentSetupContext.ts @@ -0,0 +1,27 @@ +import { shallowMount, type ComponentMountingOptions } from '@vue/test-utils'; +import { defineComponent } from 'vue'; + +type MountOptions = ComponentMountingOptions; + +/** + * A test helper utility that provides a component `setup()` context. + * This function allows running code that depends on Vue lifecycle hooks, + * such as `onMounted`, within a component's `setup` function. + */ +export function executeInComponentSetupContext(options: { + readonly setupCallback: () => void; + readonly disableAutoUnmount?: boolean; + readonly mountOptions?: MountOptions, +}): ReturnType { + const componentWrapper = shallowMount(defineComponent({ + setup() { + options.setupCallback(); + }, + // Component requires a template or render function + template: '
Test Component: setup context
', + }), options.mountOptions); + if (!options.disableAutoUnmount) { + componentWrapper.unmount(); // Ensure cleanup of callback tasks + } + return componentWrapper; +} diff --git a/tests/shared/WaitForValueChange.ts b/tests/shared/Vue/WaitForValueChange.ts similarity index 100% rename from tests/shared/WaitForValueChange.ts rename to tests/shared/Vue/WaitForValueChange.ts diff --git a/tests/shared/bootstrap/FailTestOnConsoleError.ts b/tests/shared/bootstrap/FailTestOnConsoleError.ts new file mode 100644 index 00000000..7267fef8 --- /dev/null +++ b/tests/shared/bootstrap/FailTestOnConsoleError.ts @@ -0,0 +1,23 @@ +import { + beforeEach, afterEach, vi, expect, +} from 'vitest'; +import type { FunctionKeys } from '@/TypeHelpers'; + +export function failTestOnConsoleError() { + const consoleMethodsToCheck: readonly FunctionKeys[] = [ + 'warn', + 'error', + ]; + + beforeEach(() => { + consoleMethodsToCheck.forEach((methodName) => { + vi.spyOn(console, methodName).mockClear(); + }); + }); + + afterEach(() => { + consoleMethodsToCheck.forEach((methodName) => { + expect(console[methodName]).not.toHaveBeenCalled(); + }); + }); +} diff --git a/tests/shared/bootstrap/setup.ts b/tests/shared/bootstrap/setup.ts index ff5d02ec..4c25f7a0 100644 --- a/tests/shared/bootstrap/setup.ts +++ b/tests/shared/bootstrap/setup.ts @@ -1,6 +1,8 @@ import { afterEach } from 'vitest'; import { enableAutoUnmount } from '@vue/test-utils'; import { polyfillBlob } from './BlobPolyfill'; +import { failTestOnConsoleError } from './FailTestOnConsoleError'; enableAutoUnmount(afterEach); polyfillBlob(); +failTestOnConsoleError(); diff --git a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts index 767594aa..a2abeaae 100644 --- a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts +++ b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts @@ -6,6 +6,7 @@ import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationCont import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import type { IApplicationContext } from '@/application/Context/IApplicationContext'; import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests'; +import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext'; describe('DependencyProvider', () => { describe('provideDependencies', () => { @@ -55,9 +56,14 @@ function createTransientTests() { .provideDependencies(); // act const getFactoryResult = () => { - const registeredObject = api.inject(injectionKey); - const factory = registeredObject as () => unknown; - return factory(); + const registeredFactory = api.inject(injectionKey) as () => unknown; + let factoryResult: unknown; + executeInComponentSetupContext({ + setupCallback: () => { + factoryResult = registeredFactory(); + }, + }); + return factoryResult; }; // assert itIsTransientFactory({ @@ -97,6 +103,7 @@ function createSingletonTests() { }); }; } + class ProvideDependenciesBuilder { private context: IApplicationContext = new ApplicationContextStub(); diff --git a/tests/unit/presentation/components/Scripts/Menu/Recommendation/Rating/CircleRating.spec.ts b/tests/unit/presentation/components/Scripts/Menu/Recommendation/Rating/CircleRating.spec.ts index c1f9b6da..1435cd95 100644 --- a/tests/unit/presentation/components/Scripts/Menu/Recommendation/Rating/CircleRating.spec.ts +++ b/tests/unit/presentation/components/Scripts/Menu/Recommendation/Rating/CircleRating.spec.ts @@ -13,23 +13,10 @@ describe('CircleRating.vue', () => { const currentRating = MAX_RATING - 1; // act - const wrapper = shallowMount(CircleRating, { - propsData: { - rating: currentRating, - }, + const wrapper = mountComponent({ + rating: currentRating, }); - // assert - const ratingCircles = wrapper.findAllComponents(RatingCircle); - expect(ratingCircles.length).to.equal(expectedMaxRating); - }); - it('renders the correct number of RatingCircle components for default rating', () => { - // arrange - const expectedMaxRating = MAX_RATING; - - // act - const wrapper = shallowMount(CircleRating); - // assert const ratingCircles = wrapper.findAllComponents(RatingCircle); expect(ratingCircles.length).to.equal(expectedMaxRating); @@ -42,10 +29,8 @@ describe('CircleRating.vue', () => { const expectedTotalComponents = 3; // act - const wrapper = shallowMount(CircleRating, { - propsData: { - rating: expectedTotalComponents, - }, + const wrapper = mountComponent({ + rating: expectedTotalComponents, }); // assert @@ -87,3 +72,13 @@ describe('CircleRating.vue', () => { }); }); }); + +function mountComponent(options: { + readonly rating: number, +}) { + return shallowMount(CircleRating, { + props: { + rating: options.rating, + }, + }); +} diff --git a/tests/unit/presentation/components/Scripts/Slider/UseDragHandler.spec.ts b/tests/unit/presentation/components/Scripts/Slider/UseDragHandler.spec.ts index b06bdd98..8a5ebf88 100644 --- a/tests/unit/presentation/components/Scripts/Slider/UseDragHandler.spec.ts +++ b/tests/unit/presentation/components/Scripts/Slider/UseDragHandler.spec.ts @@ -4,6 +4,8 @@ import { useDragHandler, type DragDomModifier } from '@/presentation/components/ import { ThrottleStub } from '@tests/unit/shared/Stubs/ThrottleStub'; import { type ThrottleFunction } from '@/application/Common/Timing/Throttle'; import type { ConstructorArguments } from '@/TypeHelpers'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; describe('useDragHandler', () => { describe('initially', () => { @@ -80,7 +82,7 @@ describe('useDragHandler', () => { const finalDragX = 150; const expectedDisplacementX = finalDragX - initialDragX; const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act const { displacementX } = initializeDragHandlerWithMocks({ @@ -100,7 +102,7 @@ describe('useDragHandler', () => { 'pointermove', ]; const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act initializeDragHandlerWithMocks({ @@ -153,7 +155,7 @@ describe('useDragHandler', () => { const expectedTotalThrottledEvents = 3; const throttleStub = new ThrottleStub(); const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act initializeDragHandlerWithMocks({ @@ -176,7 +178,7 @@ describe('useDragHandler', () => { const mockElement = document.createElement('div'); const initialDragX = 100; const firstDisplacementX = 10; - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act const { displacementX } = initializeDragHandlerWithMocks({ @@ -199,7 +201,7 @@ describe('useDragHandler', () => { // arrange const expectedDraggingState = false; const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act const { isDragging } = initializeDragHandlerWithMocks({ @@ -215,7 +217,7 @@ describe('useDragHandler', () => { it('removes event listeners', () => { // arrange const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act initializeDragHandlerWithMocks({ @@ -225,6 +227,27 @@ describe('useDragHandler', () => { mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 })); dragDomModifierMock.simulateEvent('pointerup', createMockPointerEvent('pointerup')); + // assert + const actualEvents = [...dragDomModifierMock.events]; + expect(actualEvents).to.have.lengthOf(0); + }); + }); + describe('on teardown', () => { + it('removes event listeners', () => { + // arrange + const teardownHook = new LifecycleHookStub(); + const mockElement = document.createElement('div'); + const dragDomModifierMock = createDragDomModifierMock(); + + // act + initializeDragHandlerWithMocks({ + draggableElementRef: ref(mockElement), + dragDomModifier: dragDomModifierMock, + onTeardown: teardownHook.getHook(), + }); + mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 })); + teardownHook.executeAllCallbacks(); + // assert const actualEvents = [...dragDomModifierMock.events]; expect(actualEvents).to.have.lengthOf(0); @@ -236,11 +259,13 @@ function initializeDragHandlerWithMocks(mocks?: { readonly dragDomModifier?: DragDomModifier; readonly draggableElementRef?: Ref; readonly throttler?: ThrottleFunction, + readonly onTeardown?: LifecycleHook, }) { return useDragHandler( mocks?.draggableElementRef ?? ref(document.createElement('div')), - mocks?.dragDomModifier ?? new DragDomModifierMock(), + mocks?.dragDomModifier ?? createDragDomModifierMock(), mocks?.throttler ?? new ThrottleStub().withImmediateExecution(true).func, + mocks?.onTeardown ?? new LifecycleHookStub().getHook(), ); } @@ -248,22 +273,25 @@ function createMockPointerEvent(...args: ConstructorArguments(); - - public addEventListenerToDocument(type: keyof DocumentEventMap, handler: EventListener): void { - this.events.set(type, handler); - } - - public removeEventListenerFromDocument(type: keyof DocumentEventMap): void { - this.events.delete(type); - } - - public simulateEvent(type: keyof DocumentEventMap, event: Event) { - const handler = this.events.get(type); - if (!handler) { - throw new Error(`No event handler registered for: ${type}`); - } - handler(event); - } +function createDragDomModifierMock(): DragDomModifier & { + simulateEvent(type: keyof DocumentEventMap, event: Event): void; + readonly events: Map; +} { + const events = new Map(); + return { + addEventListenerToDocument: (type, handler) => { + events.set(type, handler); + }, + removeEventListenerFromDocument: (type) => { + events.delete(type); + }, + simulateEvent: (type, event) => { + const handler = events.get(type); + if (!handler) { + throw new Error(`No event handler registered for: ${type}`); + } + handler(event); + }, + events, + }; } diff --git a/tests/unit/presentation/components/Scripts/Slider/UseGlobalCursor.spec.ts b/tests/unit/presentation/components/Scripts/Slider/UseGlobalCursor.spec.ts index 457cf3d0..24cf5996 100644 --- a/tests/unit/presentation/components/Scripts/Slider/UseGlobalCursor.spec.ts +++ b/tests/unit/presentation/components/Scripts/Slider/UseGlobalCursor.spec.ts @@ -1,23 +1,25 @@ import { it, describe, expect } from 'vitest'; import { - type Ref, ref, defineComponent, nextTick, + type Ref, ref, nextTick, } from 'vue'; -import { shallowMount } from '@vue/test-utils'; import { type CursorStyleDomModifier, useGlobalCursor } from '@/presentation/components/Scripts/Slider/UseGlobalCursor'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; describe('useGlobalCursor', () => { it('adds cursor style to head on activation', async () => { // arrange const expectedCursorCssStyleValue = 'pointer'; const isActive = ref(false); + const domModifier = new CursorStyleDomModifierStub(); // act - const { cursorStyleDomModifierMock } = createHookWithStubs({ isActive }); + createHookWithStubs({ isActive, domModifier }); isActive.value = true; await nextTick(); // assert - const { elementsAppendedToHead: appendedElements } = cursorStyleDomModifierMock; + const { elementsAppendedToHead: appendedElements } = domModifier; expect(appendedElements.length).to.equal(1); expect(appendedElements[0].innerHTML).toContain(expectedCursorCssStyleValue); }); @@ -25,75 +27,61 @@ describe('useGlobalCursor', () => { it('removes cursor style from head on deactivation', async () => { // arrange const isActive = ref(true); + const domModifier = new CursorStyleDomModifierStub(); // act - const { cursorStyleDomModifierMock } = createHookWithStubs({ isActive }); + createHookWithStubs({ isActive, domModifier }); await nextTick(); isActive.value = false; await nextTick(); // assert - expect(cursorStyleDomModifierMock.elementsRemovedFromHead.length).to.equal(1); + expect(domModifier.elementsRemovedFromHead.length).to.equal(1); }); it('cleans up cursor style on unmount', async () => { // arrange const isActive = ref(true); + const domModifier = new CursorStyleDomModifierStub(); + const onTeardown = new LifecycleHookStub(); // act - const { wrapper, returnObject } = mountWrapperComponent({ isActive }); - wrapper.unmount(); + createHookWithStubs({ isActive, domModifier, onTeardown: onTeardown.getHook() }); + onTeardown.executeAllCallbacks(); await nextTick(); // assert - const { cursorStyleDomModifierMock } = returnObject; - expect(cursorStyleDomModifierMock.elementsRemovedFromHead.length).to.equal(1); + expect(onTeardown.totalRegisteredCallbacks).to.be.greaterThan(0); + expect(domModifier.elementsRemovedFromHead.length).to.equal(1); }); it('does not append style to head when initially inactive', async () => { // arrange const isActive = ref(false); + const domModifier = new CursorStyleDomModifierStub(); // act - const { cursorStyleDomModifierMock } = createHookWithStubs({ isActive }); + createHookWithStubs({ isActive, domModifier }); await nextTick(); // assert - expect(cursorStyleDomModifierMock.elementsAppendedToHead.length).toBe(0); + expect(domModifier.elementsAppendedToHead.length).toBe(0); }); }); -function mountWrapperComponent(...hookOptions: Parameters) { - let returnObject: ReturnType | undefined; - const wrapper = shallowMount( - defineComponent({ - setup() { - returnObject = createHookWithStubs(...hookOptions); - }, - template: '
', - }), - ); - if (!returnObject) { - throw new Error('missing hook result'); - } - return { - wrapper, - returnObject, - }; -} - function createHookWithStubs(options?: { readonly isActive?: Ref; readonly expectedCursorCssStyleValue?: string; + readonly domModifier?: CursorStyleDomModifier; + readonly onTeardown?: LifecycleHook; }) { - const cursorStyleDomModifierMock = new CursorStyleDomModifierStub(); const hookResult = useGlobalCursor( options?.isActive ?? ref(true), options?.expectedCursorCssStyleValue ?? 'pointer', - cursorStyleDomModifierMock, + options?.domModifier ?? new CursorStyleDomModifierStub(), + options?.onTeardown ?? new LifecycleHookStub().getHook(), ); return { - cursorStyleDomModifierMock, hookResult, }; } diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts index 265f4d67..dc14fe2c 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts @@ -24,7 +24,7 @@ describe('useNodeStateChangeAggregator', () => { // arrange const expectedTreeRootRef = shallowRef(new TreeRootStub()); const currentTreeNodesStub = new UseCurrentTreeNodesStub(); - const builder = new UseNodeStateChangeAggregatorBuilder() + const builder = new TestContext() .withCurrentTreeNodes(currentTreeNodesStub.get()) .withTreeRootRef(expectedTreeRootRef); // act @@ -61,7 +61,7 @@ describe('useNodeStateChangeAggregator', () => { // arrange const nodesStub = new UseCurrentTreeNodesStub() .withQueryableNodes(createFlatCollection(expectedNodes)); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .mountWrapperComponent(); const { callback, calledArgs } = createSpyingCallback(); @@ -81,7 +81,7 @@ describe('useNodeStateChangeAggregator', () => { it(description, async () => { // arrange const nodesStub = new UseCurrentTreeNodesStub(); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .mountWrapperComponent(); const { callback, calledArgs } = createSpyingCallback(); @@ -104,7 +104,7 @@ describe('useNodeStateChangeAggregator', () => { // arrange const nodesStub = new UseCurrentTreeNodesStub() .withQueryableNodes(createFlatCollection(expectedNodes)); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .mountWrapperComponent(); const { callback, calledArgs } = createSpyingCallback(); @@ -167,7 +167,7 @@ describe('useNodeStateChangeAggregator', () => { .withQueryableNodes(createFlatCollection(initialNodes)); const nodeState = new TreeNodeStateAccessStub(); changedNode.withState(nodeState); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .mountWrapperComponent(); const { callback, calledArgs } = createSpyingCallback(); @@ -216,7 +216,7 @@ describe('useNodeStateChangeAggregator', () => { const nodesStub = new UseCurrentTreeNodesStub() .withQueryableNodes(createFlatCollection(initialNodes)); const eventsStub = new UseAutoUnsubscribedEventsStub(); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .withEventsStub(eventsStub) .mountWrapperComponent(); @@ -290,7 +290,7 @@ function createFlatCollection(nodes: readonly TreeNode[]): QueryableNodesStub { return new QueryableNodesStub().withFlattenedNodes(nodes); } -class UseNodeStateChangeAggregatorBuilder { +class TestContext { private treeRootRef: Readonly> = shallowRef(new TreeRootStub()); private currentTreeNodes: typeof useCurrentTreeNodes = new UseCurrentTreeNodesStub().get(); diff --git a/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.spec.ts b/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.spec.ts index 6508c419..27b1cc43 100644 --- a/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.spec.ts +++ b/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; import { nextTick, shallowRef } from 'vue'; import { useAutoUnsubscribedEventListener } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener'; -import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectDoesNotThrowAsync } from '@tests/shared/Assertions/ExpectThrowsAsync'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; describe('UseAutoUnsubscribedEventListener', () => { describe('startListening', () => { @@ -17,9 +17,10 @@ describe('UseAutoUnsubscribedEventListener', () => { const callback = (event: Event) => { actualEvent = event; }; + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(eventTarget, eventType, callback); + const { startListening } = context.use(); + startListening(eventTarget, eventType, callback); eventTarget.dispatchEvent(expectedEvent); // assert expect(actualEvent).to.equal(expectedEvent); @@ -33,13 +34,16 @@ describe('UseAutoUnsubscribedEventListener', () => { }; const eventTarget = new EventTarget(); const eventType: keyof HTMLElementEventMap = 'abort'; + const teardownHook = new LifecycleHookStub(); + const context = new TestContext() + .withOnTeardown(teardownHook.getHook()); // act - const { wrapper } = mountWrapper({ - setup: (listener) => listener.startListening(eventTarget, eventType, callback), - }); - wrapper.unmount(); + const { startListening } = context.use(); + startListening(eventTarget, eventType, callback); + teardownHook.executeAllCallbacks(); eventTarget.dispatchEvent(new CustomEvent(eventType)); // assert + expect(teardownHook.totalRegisteredCallbacks).to.greaterThan(0); expect(isCallbackCalled).to.equal(expectedCallbackCall); }); }); @@ -54,9 +58,10 @@ describe('UseAutoUnsubscribedEventListener', () => { const callback = (event: Event) => { actualEvent = event; }; + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(eventTargetRef, eventType, callback); + const { startListening } = context.use(); + startListening(eventTargetRef, eventType, callback); eventTarget.dispatchEvent(expectedEvent); // assert expect(actualEvent).to.equal(expectedEvent); @@ -72,9 +77,10 @@ describe('UseAutoUnsubscribedEventListener', () => { const callback = (event: Event) => { actualEvent = event; }; + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(targetRef, eventType, callback); + const { startListening } = context.use(); + startListening(targetRef, eventType, callback); targetRef.value = newValue; await nextTick(); newValue.dispatchEvent(expectedEvent); @@ -84,10 +90,11 @@ describe('UseAutoUnsubscribedEventListener', () => { it('does not throw if initial element is undefined', () => { // arrange const targetRef = shallowRef(undefined); + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); + const { startListening } = context.use(); const act = () => { - returnObject.startListening(targetRef, 'abort', () => { /* NO OP */ }); + startListening(targetRef, 'abort', () => { /* NO OP */ }); }; // assert expect(act).to.not.throw(); @@ -95,9 +102,10 @@ describe('UseAutoUnsubscribedEventListener', () => { it('does not throw when reference becomes undefined', async () => { // arrange const targetRef = shallowRef(new EventTarget()); + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(targetRef, 'abort', () => { /* NO OP */ }); + const { startListening } = context.use(); + startListening(targetRef, 'abort', () => { /* NO OP */ }); const act = async () => { targetRef.value = undefined; await nextTick(); @@ -117,9 +125,10 @@ describe('UseAutoUnsubscribedEventListener', () => { const targetRef = shallowRef(oldValue); const eventType: keyof HTMLElementEventMap = 'abort'; const expectedEvent = new CustomEvent(eventType); + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(targetRef, eventType, callback); + const { startListening } = context.use(); + startListening(targetRef, eventType, callback); targetRef.value = newValue; await nextTick(); oldValue.dispatchEvent(expectedEvent); @@ -136,11 +145,13 @@ describe('UseAutoUnsubscribedEventListener', () => { const target = new EventTarget(); const targetRef = shallowRef(target); const eventType: keyof HTMLElementEventMap = 'abort'; + const teardownHook = new LifecycleHookStub(); + const context = new TestContext() + .withOnTeardown(teardownHook.getHook()); // act - const { wrapper } = mountWrapper({ - setup: (listener) => listener.startListening(targetRef, eventType, callback), - }); - wrapper.unmount(); + const { startListening } = context.use(); + startListening(targetRef, eventType, callback); + teardownHook.executeAllCallbacks(); target.dispatchEvent(new CustomEvent(eventType)); // assert expect(isCallbackCalled).to.equal(expectedCallbackCall); @@ -149,24 +160,18 @@ describe('UseAutoUnsubscribedEventListener', () => { }); }); -function mountWrapper(options?: { - readonly constructorArgs?: Parameters, - /** Running inside `setup` allows simulating lifecycle events like unmounting. */ - readonly setup?: (returnObject: ReturnType) => void, -}) { - let returnObject: ReturnType | undefined; - const wrapper = shallowMount({ - setup() { - returnObject = useAutoUnsubscribedEventListener(...(options?.constructorArgs ?? [])); - if (options?.setup) { - options.setup(returnObject); - } - }, - template: '
', - }); - expectExists(returnObject); - return { - wrapper, - returnObject, - }; +class TestContext { + private onTeardown: LifecycleHook = new LifecycleHookStub() + .getHook(); + + public withOnTeardown(onTeardown: LifecycleHook): this { + this.onTeardown = onTeardown; + return this; + } + + public use(): ReturnType { + return useAutoUnsubscribedEventListener( + this.onTeardown, + ); + } } diff --git a/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.spec.ts b/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.spec.ts index 38b73d01..8b3c53d3 100644 --- a/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.spec.ts +++ b/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.spec.ts @@ -1,43 +1,38 @@ import { describe, it, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents'; import { EventSubscriptionCollectionStub } from '@tests/unit/shared/Stubs/EventSubscriptionCollectionStub'; import { EventSubscriptionStub } from '@tests/unit/shared/Stubs/EventSubscriptionStub'; import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection'; import type { FunctionKeys } from '@/TypeHelpers'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; +import type { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection'; describe('UseAutoUnsubscribedEvents', () => { describe('event collection handling', () => { it('returns the provided event collection when initialized', () => { // arrange const expectedEvents = new EventSubscriptionCollectionStub(); + const context = new TestContext() + .withEvents(expectedEvents); // act - const { events: actualEvents } = useAutoUnsubscribedEvents(expectedEvents); + const { events: actualEvents } = context.use(); // assert expect(actualEvents).to.equal(expectedEvents); }); - it('uses a default event collection when none is provided during initialization', () => { - // arrange - const expectedType = EventSubscriptionCollection; - - // act - const { events: actualEvents } = useAutoUnsubscribedEvents(); - - // assert - expect(actualEvents).to.be.instanceOf(expectedType); - }); - it('throws error when there are existing subscriptions', () => { // arrange const expectedError = 'there are existing subscriptions, this may lead to side-effects'; const events = new EventSubscriptionCollectionStub(); events.register([new EventSubscriptionStub(), new EventSubscriptionStub()]); + const context = new TestContext() + .withEvents(events); // act - const act = () => useAutoUnsubscribedEvents(events); + const act = () => context.use(); // assert expect(act).to.throw(expectedError); @@ -48,21 +43,44 @@ describe('UseAutoUnsubscribedEvents', () => { // arrange const events = new EventSubscriptionCollectionStub(); const expectedCall: FunctionKeys = 'unsubscribeAll'; - const stubComponent = shallowMount({ - setup() { - useAutoUnsubscribedEvents(events); - events.register([new EventSubscriptionStub(), new EventSubscriptionStub()]); - }, - template: '
', - }); - events.callHistory.length = 0; + const onTeardown = new LifecycleHookStub(); + const context = new TestContext() + .withEvents(events) + .withOnTeardown(onTeardown.getHook()); // act - stubComponent.unmount(); + context.use(); + events.register([new EventSubscriptionStub(), new EventSubscriptionStub()]); + events.callHistory.length = 0; + onTeardown.executeAllCallbacks(); // assert + expect(onTeardown.totalRegisteredCallbacks).to.be.greaterThan(0); expect(events.callHistory).to.have.lengthOf(1); expect(events.callHistory[0].methodName).to.equal(expectedCall); }); }); }); + +class TestContext { + private onTeardown: LifecycleHook = new LifecycleHookStub().getHook(); + + private events: IEventSubscriptionCollection = new EventSubscriptionCollectionStub(); + + public withOnTeardown(onTeardown: LifecycleHook): this { + this.onTeardown = onTeardown; + return this; + } + + public withEvents(events: IEventSubscriptionCollection): this { + this.events = events; + return this; + } + + public use(): ReturnType { + return useAutoUnsubscribedEvents( + this.events, + this.onTeardown, + ); + } +} diff --git a/tests/unit/presentation/components/Shared/Icon/UseSvgLoader.spec.ts b/tests/unit/presentation/components/Shared/Icon/UseSvgLoader.spec.ts index 6b0fba23..f4b96ebb 100644 --- a/tests/unit/presentation/components/Shared/Icon/UseSvgLoader.spec.ts +++ b/tests/unit/presentation/components/Shared/Icon/UseSvgLoader.spec.ts @@ -4,7 +4,7 @@ import { import { ref } from 'vue'; import type { IconName } from '@/presentation/components/Shared/Icon/IconName'; import { type FileLoaders, clearIconCache, useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader'; -import { waitForValueChange } from '@tests/shared/WaitForValueChange'; +import { waitForValueChange } from '@tests/shared/Vue/WaitForValueChange'; describe('useSvgLoader', () => { beforeEach(() => { diff --git a/tests/unit/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.spec.ts b/tests/unit/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.spec.ts index 213f022d..351c8c4e 100644 --- a/tests/unit/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.spec.ts +++ b/tests/unit/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; -import { ref, nextTick, defineComponent } from 'vue'; +import { ref, nextTick } from 'vue'; import { useLockBodyBackgroundScroll } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll'; import type { ScrollDomStateAccessor } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor'; +import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext'; import { DomStateChangeTestScenarios } from './DomStateChangeTestScenarios'; describe('useLockBodyBackgroundScroll', () => { @@ -13,7 +13,7 @@ describe('useLockBodyBackgroundScroll', () => { const isInitiallyActive = true; // act - createComponent(isInitiallyActive, dom); + mountWrapperComponent(isInitiallyActive, dom); await nextTick(); }); }); @@ -22,7 +22,7 @@ describe('useLockBodyBackgroundScroll', () => { const isInitiallyActive = false; // act - const { initialDomState, actualDomState } = createComponent(isInitiallyActive); + const { initialDomState, actualDomState } = mountWrapperComponent(isInitiallyActive); await nextTick(); // assert @@ -34,7 +34,7 @@ describe('useLockBodyBackgroundScroll', () => { itEachScrollBlockEffect(async (dom) => { // arrange const isInitiallyActive = false; - const { isActive } = createComponent(isInitiallyActive, dom); + const { isActive } = mountWrapperComponent(isInitiallyActive, dom); // act isActive.value = true; @@ -44,7 +44,9 @@ describe('useLockBodyBackgroundScroll', () => { it('reverts to initial styles when deactivated', async () => { // arrange const isInitiallyActive = true; - const { isActive, initialDomState, actualDomState } = createComponent(isInitiallyActive); + const { + isActive, initialDomState, actualDomState, + } = mountWrapperComponent(isInitiallyActive); // act isActive.value = false; @@ -57,7 +59,9 @@ describe('useLockBodyBackgroundScroll', () => { it('restores original styles on unmount', async () => { // arrange const isInitiallyActive = true; - const { component, initialDomState, actualDomState } = createComponent(isInitiallyActive); + const { + component, initialDomState, actualDomState, + } = mountWrapperComponent(isInitiallyActive); // act component.unmount(); @@ -68,19 +72,19 @@ describe('useLockBodyBackgroundScroll', () => { }); }); -function createComponent( +function mountWrapperComponent( initialIsActiveValue: boolean, dom?: ScrollDomStateAccessor, ) { const actualDomState = dom ?? createMockDomStateAccessor(); const initialDomState = { ...actualDomState }; const isActive = ref(initialIsActiveValue); - const component = shallowMount(defineComponent({ - setup() { + const component = executeInComponentSetupContext({ + setupCallback: () => { useLockBodyBackgroundScroll(isActive, actualDomState); }, - template: '
', - })); + disableAutoUnmount: true, + }); return { component, isActive, initialDomState, actualDomState, }; diff --git a/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts b/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts index 019d0826..a65596fe 100644 --- a/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts +++ b/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts @@ -182,7 +182,7 @@ function mountComponent(options: { }, [COMPONENT_MODAL_CONTENT_NAME]: { name: COMPONENT_MODAL_CONTENT_NAME, - template: '', + template: '
', }, }, }, diff --git a/tests/unit/presentation/components/Shared/Resize/UseResizeObserver.spec.ts b/tests/unit/presentation/components/Shared/Resize/UseResizeObserver.spec.ts index c1abcdb4..e1c46f0c 100644 --- a/tests/unit/presentation/components/Shared/Resize/UseResizeObserver.spec.ts +++ b/tests/unit/presentation/components/Shared/Resize/UseResizeObserver.spec.ts @@ -1,9 +1,10 @@ import { shallowRef } from 'vue'; -import { useResizeObserver, type LifecycleHookRegistration, type ObservedElementReference } from '@/presentation/components/Shared/Hooks/Resize/UseResizeObserver'; +import { useResizeObserver, type ObservedElementReference } from '@/presentation/components/Shared/Hooks/Resize/UseResizeObserver'; import { flushPromiseResolutionQueue } from '@tests/unit/shared/PromiseInspection'; import type { AnimationFrameLimiter } from '@/presentation/components/Shared/Hooks/Resize/UseAnimationFrameLimiter'; import { ThrottleStub } from '@tests/unit/shared/Stubs/ThrottleStub'; -import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; describe('UseResizeObserver', () => { it('registers observer once mounted', async () => { @@ -30,18 +31,16 @@ describe('UseResizeObserver', () => { resizeObserverStub.disconnect = () => { isObserverDisconnected = true; }; - let teardownCallback: (() => void) | undefined; + const teardownHook = new LifecycleHookStub(); // act new TestContext() .withResizeObserver(resizeObserverStub) - .withOnTeardown((callback) => { - teardownCallback = callback; - }) + .withOnTeardown(teardownHook.getHook()) .useResizeObserver(); await flushPromiseResolutionQueue(); - expectExists(teardownCallback); - teardownCallback(); + teardownHook.executeAllCallbacks(); // assert + expect(teardownHook.totalRegisteredCallbacks).to.be.greaterThan(0); expect(isObserverDisconnected).to.equal(true); }); }); @@ -66,9 +65,12 @@ class TestContext { private observedElementRef: ObservedElementReference = shallowRef(document.createElement('div')); - private onSetup: LifecycleHookRegistration = (callback) => { callback(); }; + private onSetup: LifecycleHook = new LifecycleHookStub() + .withInvokeCallbackImmediately(true) + .getHook(); - private onTeardown: LifecycleHookRegistration = () => { }; + private onTeardown: LifecycleHook = new LifecycleHookStub() + .getHook(); public withResizeObserver(resizeObserver: ResizeObserver): this { this.resizeObserver = resizeObserver; @@ -80,12 +82,12 @@ class TestContext { return this; } - public withOnSetup(onSetup: LifecycleHookRegistration): this { + public withOnSetup(onSetup: LifecycleHook): this { this.onSetup = onSetup; return this; } - public withOnTeardown(onTeardown: LifecycleHookRegistration): this { + public withOnTeardown(onTeardown: LifecycleHook): this { this.onTeardown = onTeardown; return this; } diff --git a/tests/unit/shared/Stubs/LifecycleHookStub.ts b/tests/unit/shared/Stubs/LifecycleHookStub.ts new file mode 100644 index 00000000..87e2d11a --- /dev/null +++ b/tests/unit/shared/Stubs/LifecycleHookStub.ts @@ -0,0 +1,31 @@ +import type { LifecycleHookCallback, LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; + +export class LifecycleHookStub { + private registeredCallbacks = new Array(); + + private invokeCallbackImmediately = false; + + public withInvokeCallbackImmediately(callImmediatelyOnRegistration: boolean): this { + this.invokeCallbackImmediately = callImmediatelyOnRegistration; + return this; + } + + public get totalRegisteredCallbacks(): number { + return this.registeredCallbacks.length; + } + + public executeAllCallbacks() { + for (const callback of this.registeredCallbacks) { + callback(); + } + } + + public getHook(): LifecycleHook { + return (callback: LifecycleHookCallback) => { + this.registeredCallbacks.push(callback); + if (this.invokeCallbackImmediately) { + callback(); + } + }; + } +} diff --git a/tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts b/tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts index 49909782..9f71ead6 100644 --- a/tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts +++ b/tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts @@ -9,6 +9,10 @@ export class VueDependencyInjectionApiStub implements VueDependencyInjectionApi } public inject(key: InjectionKey): T { - return this.injections.get(key) as T; + const providedValue = this.injections.get(key); + if (providedValue === undefined) { + throw new Error(`[VueDependencyInjectionApiStub] No value provided for key: ${String(key)}`); + } + return providedValue as T; } }