Skip to content

Commit 4ca6de0

Browse files
committed
test(e2e): delivery address CRUD tests with MSW
Adds e2e tests for the skeleton template's account.addresses route using MSW to mock the Customer Account API. This avoids requiring real customer credentials while testing the full CRUD lifecycle. Key design decisions: - MSW scenario uses mutable closure state so mutations (create/update/delete) modify the address array and subsequent queries reflect those changes. This enables serial tests to verify state transitions across operations. - DeliveryAddressUtil fixture follows the deep module pattern: entity locators (getExistingAddresses, getCreateAddressForm) are public while button mechanics are hidden inside action methods (createAddress, updateAddress, deleteAddress). - Existing address forms are identified by filtering for forms containing a Save button, avoiding the fragile `id` attribute selector (which uses GID URLs with special characters). - Checkbox selectors use getByRole('checkbox') scoped to the form rather than getByLabel, because all address forms share the same htmlFor/id attributes for the default-address checkbox, causing label-based resolution to be ambiguous. Test coverage: Read (count, form visibility, default checkbox state), Create (submit + verify in list), Update (field change persists), Delete (count decreases, empty state when all removed).
1 parent 343d509 commit 4ca6de0

File tree

5 files changed

+397
-0
lines changed

5 files changed

+397
-0
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {expect, Locator, Page} from '@playwright/test';
2+
3+
export interface AddressFormData {
4+
firstName: string;
5+
lastName: string;
6+
company?: string;
7+
address1: string;
8+
address2?: string;
9+
city: string;
10+
zoneCode: string;
11+
zip: string;
12+
territoryCode: string;
13+
phoneNumber?: string;
14+
defaultAddress?: boolean;
15+
}
16+
17+
export class DeliveryAddressUtil {
18+
constructor(private page: Page) {}
19+
20+
async navigateToAddresses() {
21+
await this.page.goto('/account/addresses');
22+
await expect(
23+
this.page.getByRole('heading', {level: 2, name: 'Addresses'}),
24+
).toBeVisible();
25+
}
26+
27+
/**
28+
* Returns each existing address form (excluding the create form).
29+
* The create form has id="NEW_ADDRESS_ID"; existing forms have GID-based ids.
30+
* We select forms with Save/Delete buttons, which only appear on existing addresses.
31+
*/
32+
getExistingAddresses(): Locator {
33+
return this.page
34+
.locator('form')
35+
.filter({has: this.page.getByRole('button', {name: 'Save'})});
36+
}
37+
38+
getCreateAddressForm(): Locator {
39+
return this.page.locator('form#NEW_ADDRESS_ID');
40+
}
41+
42+
getEmptyState(): Locator {
43+
return this.page.getByText('You have no addresses saved.');
44+
}
45+
46+
async fillAddressForm(form: Locator, data: AddressFormData) {
47+
await form.getByLabel('First name').fill(data.firstName);
48+
await form.getByLabel('Last name').fill(data.lastName);
49+
if (data.company !== undefined) {
50+
await form.getByLabel('Company').fill(data.company);
51+
}
52+
await form.getByLabel('Address line 1').fill(data.address1);
53+
if (data.address2 !== undefined) {
54+
await form.getByLabel('Address line 2').fill(data.address2);
55+
}
56+
await form.getByLabel('City').fill(data.city);
57+
await form.getByLabel('State/Province').fill(data.zoneCode);
58+
await form.getByLabel('Zip').fill(data.zip);
59+
await form.getByLabel('territoryCode').fill(data.territoryCode);
60+
if (data.phoneNumber !== undefined) {
61+
await form.getByLabel('Phone Number').fill(data.phoneNumber);
62+
}
63+
if (data.defaultAddress !== undefined) {
64+
const checkbox = form.getByRole('checkbox');
65+
const isChecked = await checkbox.isChecked();
66+
if (data.defaultAddress !== isChecked) {
67+
await checkbox.click();
68+
}
69+
}
70+
}
71+
72+
async createAddress(data: AddressFormData) {
73+
const form = this.getCreateAddressForm();
74+
await this.fillAddressForm(form, data);
75+
const createButton = form.getByRole('button', {name: 'Create'});
76+
await createButton.click();
77+
await expect(createButton).toHaveText('Create', {timeout: 10000});
78+
}
79+
80+
async updateAddress(form: Locator, data: Partial<AddressFormData>) {
81+
if (data.firstName !== undefined)
82+
await form.getByLabel('First name').fill(data.firstName);
83+
if (data.lastName !== undefined)
84+
await form.getByLabel('Last name').fill(data.lastName);
85+
if (data.company !== undefined)
86+
await form.getByLabel('Company').fill(data.company);
87+
if (data.address1 !== undefined)
88+
await form.getByLabel('Address line 1').fill(data.address1);
89+
if (data.address2 !== undefined)
90+
await form.getByLabel('Address line 2').fill(data.address2);
91+
if (data.city !== undefined) await form.getByLabel('City').fill(data.city);
92+
if (data.zoneCode !== undefined)
93+
await form.getByLabel('State/Province').fill(data.zoneCode);
94+
if (data.zip !== undefined) await form.getByLabel('Zip').fill(data.zip);
95+
if (data.territoryCode !== undefined)
96+
await form.getByLabel('territoryCode').fill(data.territoryCode);
97+
if (data.phoneNumber !== undefined)
98+
await form.getByLabel('Phone Number').fill(data.phoneNumber);
99+
100+
const saveButton = form.getByRole('button', {name: 'Save'});
101+
await saveButton.click();
102+
await expect(saveButton).toHaveText('Save', {timeout: 10000});
103+
}
104+
105+
async deleteAddress(form: Locator) {
106+
const deleteButton = form.getByRole('button', {name: 'Delete'});
107+
await deleteButton.click();
108+
await expect(deleteButton).not.toBeVisible({timeout: 10000});
109+
}
110+
111+
async assertAddressCount(count: number) {
112+
if (count === 0) {
113+
await expect(this.getEmptyState()).toBeVisible();
114+
return;
115+
}
116+
await expect(this.getExistingAddresses()).toHaveCount(count);
117+
}
118+
119+
async assertAddressVisible(data: {firstName: string; address1: string}) {
120+
const addressForms = this.getExistingAddresses();
121+
const matchingForm = addressForms.filter({
122+
has: this.page.locator(
123+
`input[name="firstName"][value="${data.firstName}"]`,
124+
),
125+
});
126+
await expect(matchingForm).toHaveCount(1);
127+
await expect(matchingForm.locator(`input[name="address1"]`)).toHaveValue(
128+
data.address1,
129+
);
130+
}
131+
}

e2e/fixtures/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export {CartUtil} from './cart-utils';
1717
export {DiscountUtil} from './discount-utils';
1818
export {GiftCardUtil} from './gift-card-utils';
1919
export {AccountUtil} from './account-utils';
20+
export {DeliveryAddressUtil} from './delivery-address-utils';
2021
export {mockCustomerAccountOperation} from './msw/graphql';
2122
export {MSW_SCENARIOS} from './msw/scenarios';
2223

e2e/fixtures/msw/handlers.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import type {RequestHandler} from 'msw';
22
import {CUSTOMER_DETAILS_QUERY} from '../../../templates/skeleton/app/graphql/customer-account/CustomerDetailsQuery';
33
import {CUSTOMER_ORDERS_QUERY} from '../../../templates/skeleton/app/graphql/customer-account/CustomerOrdersQuery';
4+
import {
5+
CREATE_ADDRESS_MUTATION,
6+
UPDATE_ADDRESS_MUTATION,
7+
DELETE_ADDRESS_MUTATION,
8+
} from '../../../templates/skeleton/app/graphql/customer-account/CustomerAddressMutations';
49
import {mockCustomerAccountOperation} from './graphql';
510
import {MSW_SCENARIOS, MswScenario} from './scenarios';
611
import {
12+
AddressFragment,
713
CustomerDetailsQuery,
814
CustomerOrdersQuery,
915
} from '../../../templates/skeleton/customer-accountapi.generated';
@@ -91,6 +97,146 @@ scenarios.set('customer-account-logged-in', {
9197
mocksCustomerAccountApi: true,
9298
});
9399

100+
/**
101+
* Delivery addresses scenario with mutable state.
102+
* A closure-scoped address array allows mutations to modify the list
103+
* so subsequent CustomerDetailsQuery calls reflect CRUD changes.
104+
*/
105+
function createDeliveryAddressesScenario(): MswScenarioMeta {
106+
let nextAddressId = 3;
107+
const addresses: AddressFragment[] = [
108+
{
109+
id: 'gid://shopify/CustomerAddress/1',
110+
formatted: ['123 Main St', 'Anytown ON M5V 2H1', 'Canada'],
111+
firstName: 'Taylor',
112+
lastName: 'E2E',
113+
company: 'Shopify',
114+
address1: '123 Main St',
115+
address2: '',
116+
territoryCode: 'CA',
117+
zoneCode: 'ON',
118+
city: 'Anytown',
119+
zip: 'M5V 2H1',
120+
phoneNumber: '+16135551111',
121+
},
122+
{
123+
id: 'gid://shopify/CustomerAddress/2',
124+
formatted: ['456 Oak Ave', 'Springfield IL 62704', 'United States'],
125+
firstName: 'Sam',
126+
lastName: 'Test',
127+
company: '',
128+
address1: '456 Oak Ave',
129+
address2: 'Apt 2B',
130+
territoryCode: 'US',
131+
zoneCode: 'IL',
132+
city: 'Springfield',
133+
zip: '62704',
134+
phoneNumber: '+12175559999',
135+
},
136+
];
137+
138+
let defaultAddressId: string | null = addresses[0].id;
139+
140+
function getDefaultAddress(): AddressFragment | null {
141+
return addresses.find((a) => a.id === defaultAddressId) ?? null;
142+
}
143+
144+
return {
145+
handlers: [
146+
mockCustomerAccountOperation(CUSTOMER_DETAILS_QUERY, () => ({
147+
customer: {
148+
id: 'gid://shopify/Customer/123',
149+
firstName: 'Taylor',
150+
lastName: 'E2E',
151+
defaultAddress: getDefaultAddress(),
152+
addresses: {nodes: [...addresses]},
153+
},
154+
})),
155+
mockCustomerAccountOperation(
156+
CUSTOMER_ORDERS_QUERY,
157+
() => customerOrdersMock,
158+
),
159+
mockCustomerAccountOperation(CREATE_ADDRESS_MUTATION, ({variables}) => {
160+
const id = `gid://shopify/CustomerAddress/${nextAddressId++}`;
161+
const newAddress: AddressFragment = {
162+
id,
163+
formatted: [
164+
variables.address.address1 ?? '',
165+
`${variables.address.city ?? ''} ${variables.address.zoneCode ?? ''} ${variables.address.zip ?? ''}`,
166+
variables.address.territoryCode ?? '',
167+
],
168+
firstName: variables.address.firstName ?? '',
169+
lastName: variables.address.lastName ?? '',
170+
company: variables.address.company ?? '',
171+
address1: variables.address.address1 ?? '',
172+
address2: variables.address.address2 ?? '',
173+
territoryCode: variables.address.territoryCode ?? '',
174+
zoneCode: variables.address.zoneCode ?? '',
175+
city: variables.address.city ?? '',
176+
zip: variables.address.zip ?? '',
177+
phoneNumber: variables.address.phoneNumber ?? '',
178+
};
179+
addresses.push(newAddress);
180+
if (variables.defaultAddress) {
181+
defaultAddressId = id;
182+
}
183+
return {
184+
customerAddressCreate: {
185+
customerAddress: {id},
186+
userErrors: [],
187+
},
188+
};
189+
}),
190+
mockCustomerAccountOperation(UPDATE_ADDRESS_MUTATION, ({variables}) => {
191+
const index = addresses.findIndex((a) => a.id === variables.addressId);
192+
if (index !== -1) {
193+
addresses[index] = {
194+
...addresses[index],
195+
...variables.address,
196+
formatted: [
197+
variables.address.address1 ?? addresses[index].address1 ?? '',
198+
`${variables.address.city ?? addresses[index].city ?? ''} ${variables.address.zoneCode ?? addresses[index].zoneCode ?? ''} ${variables.address.zip ?? addresses[index].zip ?? ''}`,
199+
variables.address.territoryCode ??
200+
addresses[index].territoryCode ??
201+
'',
202+
],
203+
};
204+
}
205+
if (variables.defaultAddress) {
206+
defaultAddressId = variables.addressId;
207+
}
208+
return {
209+
customerAddressUpdate: {
210+
customerAddress: {id: variables.addressId},
211+
userErrors: [],
212+
},
213+
};
214+
}),
215+
mockCustomerAccountOperation(DELETE_ADDRESS_MUTATION, ({variables}) => {
216+
const index = addresses.findIndex((a) => a.id === variables.addressId);
217+
if (index !== -1) {
218+
addresses.splice(index, 1);
219+
}
220+
if (defaultAddressId === variables.addressId) {
221+
defaultAddressId = addresses[0]?.id ?? null;
222+
}
223+
return {
224+
customerAddressDelete: {
225+
deletedAddressId: variables.addressId,
226+
userErrors: [],
227+
},
228+
};
229+
}),
230+
],
231+
mocksCustomerAccountApi: true,
232+
};
233+
}
234+
235+
scenarios.set(
236+
MSW_SCENARIOS.deliveryAddresses,
237+
createDeliveryAddressesScenario(),
238+
);
239+
94240
function isMswScenario(scenario: string): scenario is MswScenario {
95241
return scenarios.has(scenario);
96242
}

e2e/fixtures/msw/scenarios.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export const MSW_SCENARIOS = {
22
customerAccountLoggedIn: 'customer-account-logged-in',
3+
deliveryAddresses: 'delivery-addresses',
34
} as const;
45

56
export type MswScenario = (typeof MSW_SCENARIOS)[keyof typeof MSW_SCENARIOS];

0 commit comments

Comments
 (0)