vitalets / playwright-bdd

BDD testing with Playwright runner
https://vitalets.github.io/playwright-bdd/
MIT License
273 stars 32 forks source link

Question: Testing multiple roles together #185

Closed amuresia closed 1 month ago

amuresia commented 1 month ago

Hi! I was wondering if there are any suggestions on how to test a scenario like the one below (ideally by using storage states that are saved prior to running the test i.e. admin.json and user.json) Example scenario

Given I log in as "admin@example.com"
When I go to the Admin panel
And I make "user@example.com" an admin
And I log in as "user@example.com"
Then I can see the Admin panel

The bit where I get stuck is the second login. I don’t know how to tell playwright that it should use the storage state of admin@example.com up until the login and then another context/ page with a different storage state to continue the test.

The playwright documentation for testing multiple roles together makes sense but I am struggling to come up with a best practice to apply it to playwright-bdd.

I came across the isolation documentation for playwright and it looks like what I need but I don’t see how to integrate it in my project.

To expand on the above, I would appreciate any help creating and switching between contexts that use different storage states. Some of my tests are nested on purpose because they test functionality of a chat application where let’s say I need to send a message as userX and then like it as userY and then confirm that userX received a notification when the message was liked so I need to swap between users as part of the same test.

I don’t have an issue saving various state files to disk, but I am struggling to create contexts that use them as part of the same test.

I looked at https://github.com/vitalets/playwright-bdd/issues/184 but my use case seems more complicated, as the same test needs to use multiple storage states.

Any suggestions would be very much appreciated, thanks!

vitalets commented 1 month ago

Hi @amuresia ! There was a similar request in discord - setting storageState dynamically in step, pls check out the discussion: https://discord.com/channels/807756831384403968/1254447424450199634

The main idea - create pages fixture and add pages dynamically to it using different storageStates.

Here is the implementation we ended up: https://github.com/MaxEinEp/playwright-bdd-example/blob/main/test/steps/genericSteps.ts#L16

That approach should work for chat app as well. Lets imagine the following feature:

Feature: Cross-user messaging

    Scenario: Send and receive message
        Given user "A" logged-in as "a@example.com"
        And user "B" logged-in as "b@example.com"
        When user "A" sends to user "B" a message "Hello" 
        Then user "B" sees in the chat with user "A" message "Hello"

pages fixture is initially empty object:

export const test = base.extend({
  pages: async ({}, use) => {
    const pages = {} // <- will be initialized in steps
    await use(pages);
    // todo: close created browser contexts if any
  },
}); 

Steps:

Given('user {string} logged-in as {string}', async ({ pages }, user: string, email: string) => {
    const context = await browser.newContext( { storageState:`${email}.json` } );
    const page = await auth.context.newPage();
    pages[user] = { context, page };
});

When('user {string} sends to user {receiver} a message {message}', async ({ pages }, sender: string, receiver: string, message: string) => {
    await pages[sender].page.getByRole('textbox').fill(message);
    // ...
});

// similar for Then...
amuresia commented 1 month ago

Thanks so much @vitalets for the quick reply and the great suggestion! I got my tests to work using your snippets. There are a couple of items left on my list to wrap things up and perhaps you can point me to the right direction.

  1. Does this approach support typescript? I am not sure how to tell my steps that pages[sender].page is a Page object and pages[sender].context is a Context object and benefit from code completion in my code editor. I tried to define it in the fixture
    
    import { test as base, createBdd } from 'playwright-bdd';
    import { BrowserContext, Page } from 'playwright';

export type PageContext = { context: BrowserContext; page: Page; };

export const test = base.extend({ // eslint-disable-next-line no-empty-pattern pages: async ({}, use) => { const pages: PageContext[] = []; await use(pages); // todo: close created browser contexts if any }, });

export const { Given, When, Then } = createBdd(test);

And even tried to enforce it in the step

When( 'user {string} creates a task', async ({ pages } : {pages: PageContext[]}, user: string) => { await pages[user].page.goto('/'); ... await pages[user].page.waitForSelector('h6'); } );

but `pages[user].page` is defined as `any` and code completion doesn't work.

2. When and how should I close the browser pages and contexts?

// todo: close created browser contexts if any


I am not sure when to call the `.close()` action as if I do it in the `pages` fixture then I prematurely lose the context set in `Given('user {string} logged-in as {string}', async ({ pages }, user: string, email: string)` and I won't have it available in subsequent steps
If I add a final step `And all users sign out` it might never be reached if a previous step fails.

Thanks again for your support!
vitalets commented 1 month ago

Yes, we can make typescript happy. I suggest to create pages fixture as object, not array - in that case it's easier to find context for particular user. Here is a example (with closing contexts):

export type PageContext = {
  context: BrowserContext;
  page: Page;
};

export type PagesFixture = Record<string, PageContext>;

export const test = base.extend({
  // eslint-disable-next-line no-empty-pattern
  pages: async ({}, use) => {
    const pages = {} as PagesFixture;
    await use(pages);
    // close created browser contexts if any
    for (const user of Object.keys(pages)) await pages[user]?.context.close();
  },
});

// step
When(
  'user {string} creates a task',
  async ({ pages }, user: string) => {
    await pages[user].page.goto('/'); // <- should be typed correctly
    ...
    await pages[user].page.waitForSelector('h6');
  }
);

The only caveat here - if there is no step user {string} logged-in as, then pages[user].page will be undefined in subsequent steps, although Typescript will not highlight that. I think this is fair enough.

This is really useful authentication example. When done and If possible - feel free to share the link to your code and I will add it to docs.

amuresia commented 1 month ago

Thanks @vitalets! With your guidance I managed to get code completion to work again. For me, it doesn't work only by typing the pages in the pages fixture, perhaps this is due to my local setup.

const pages = {} as PagesFixture;

I also have to explicitly type it in the steps

When(
  'user {string} creates a task',
  async ({ pages }: { pages: PagesFixture }, user: string) => {
...

and that will make sure that pages[user].page is of type Page. The only thing that I noticed is that there are no video recordings for features that use this fixture. In my playwright.config.ts I have

{
      name: 'chromium',
      timeout: 60_000,
      expect: { timeout: 60_000 },
      use: {
        headless: false,
        baseURL,
        screenshot: 'only-on-failure',
        video: 'on',
        trace: 'retain-on-failure',
      },
    }

but if a test fails I only get screenshots and traces.

vitalets commented 1 month ago

For typing I assume we need to provide type to test.extend:

export const test = base.extend<{ pages: PagesFixture }>({
  // eslint-disable-next-line no-empty-pattern
  pages: async ({}, use) => {
    const pages = {} as PagesFixture;
    await use(pages);
    // close created browser contexts if any
    for (const user of Object.keys(pages)) await pages[user]?.context.close();
  },
});

About video I will check. Maybe Playwright records video only for default context.

vitalets commented 1 month ago

About video I will check. Maybe Playwright records video only for default context.

Here is the discussion: https://github.com/microsoft/playwright/issues/21994 And the root one: https://github.com/microsoft/playwright/issues/14813

amuresia commented 1 month ago

Yes, I guess some default setting are not being passed on because videos are saved when I explicitly set the video directory in the step that creates the context and page.

Given(
  'user {string} logged-in',
  async ({ browser, pages }, user: string, email: string) => {
    const context = await browser.newContext({
      storageState: `.playwright/auth/${user}.json`,
      recordVideo: { dir: `.playwright/videos` },
    });
    const page = await context.newPage();
    pages[user] = { context, page };
  }
);

But as indicated in the above issue the video is not included in the cucumber report. To have it included, I created another fixture called saveVideos.

export const test = base.extend<{ pages: PagesFixture; saveVideos: void }>({
  // eslint-disable-next-line no-empty-pattern
  pages: async ({}, use) => {
    const pages = {} as PagesFixture;
    await use(pages);
    // close created browser contexts if any
    for (const user of Object.keys(pages)) {
      await pages[user]?.context.close();
    }
  },
  saveVideos: [
    async ({ pages }, use, testInfo) => {
      await use();
      Object.keys(pages).forEach(async (user) => {
        const video = await pages[user].page.video().path();
        testInfo.attachments.push({
          name: `${user}`,
          path: video,
          contentType: 'video/webm',
        });
      });
    },
    { scope: 'test', auto: true },
  ],
});
vitalets commented 1 month ago

Added example with dynamic authentication in steps. Linked to @amuresia comment about video saving.