DEV Community

Viktorija Filipov
Viktorija Filipov

Posted on

Cypress Workshop Part 9: Reusability, Page Object Pattern, Commands

Updates

Before we begin with this lesson, let’s do some updates for our code:

1: In package.json file, edit run command for headed run to be:

"cypress-headed": "cypress run --headed -b chrome"

The reason for this is adding a headed flag, so we can see test execution UI when we run this terminal command.

2: Add new fixture file books.json in fixtures containing all the books in bookstore app:

{
    "collection1": {
        "Git": "Git Pocket Guide",
        "DesignPatternsJS": "Learning JavaScript Design Patterns",
        "API": "Designing Evolvable Web APIs with ASP.NET",
        "SpeakingJS": "Speaking JavaScript",
        "Don'tKnowJS": "You Don't Know JS",
        "JSApps": "Programming JavaScript Applications",
        "JSSecond": "Eloquent JavaScript, Second Edition",
        "ECMA": "Understanding ECMAScript 6"
    }
}
Enter fullscreen mode Exit fullscreen mode

Books from bookstore app

3: I have created one user for bookstore app we will use for login for today’s lesson, so let’s add that second user login info to our users.json fixture. The final users.json fixture should now look like:

{
  "user1": {
    "firstName": "Harvey",
    "lastName": "Specter",
    "age": "40",
    "userEmail": "specter@example.com",
    "salary": "700000",
    "department": "legal"
  },
  "user2": {
    "username": "repartner",
    "password": "Test123456!"
  }
}
Enter fullscreen mode Exit fullscreen mode

Commands

Ok, so let’s first start with commands in Cypress. What are commands?

Cypress custom commands are described by users and not the default commands from Cypress. These customized commands are used to create the test steps that are repeated in an automation flow.

We can add and overwrite an already pre-existing command. They should be placed in the commands.js file within the support folder present in the Cypress project.

So, essentially, this Cypress feature allows us to create customized Cypress commands for reusable pieces of code. We want to write custom commands for all functions that are globally repeating throughout the project.

For now, I came up with three custom commands we can write and use everywhere in our tests, so those are global, general commands to handle some actions we will repeat in few places but are not strictly related to any particular page in our app.

ℹ️ Learn more about Cypress custom commands: commands

1: Open commands.js file and add these 3 commands to it:

Cypress.Commands.add('verifyWindowAlertText', (alertText) => {
  cy.once('window:alert', (str) => {
    expect(str).to.equal(alertText);
  });
});

Cypress.Commands.add('elementVisible', (locator) => {
  cy.wrap(locator).each((index) => {
    cy.get(index).then((el) => {
      cy.get(el).should('be.visible');
    });
  });
});

Cypress.Commands.add('textExists', (text) => {
  cy.wrap(text).each((index) => {
    cy.contains(index).should('exist');
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

verifyWindowAlertText command will help us to verify alert text on some page. If it is called in the test, it will be executed once for a single alert. If we want to verify some other alert within the same test, we will call it again.

elementVisible command will help us asset existence of multiple elements so that we don’t have to write assertion code for each element separately. We will just provide an array of elements and call this command to check them all.

textExists similarly to the previous command, this one will assert that an array of text exist on the page.

Page Object Pattern

Page Object Model is a design pattern in the automation world which has been famous for its test maintenance approach and avoiding code duplication. A page object is a class that represents a page in the web application. Under this model, the overall web application breaks down into logical pages. Each page of the web application generally corresponds to one class in the page object, but can even map to multiple classes also, depending on the classification of the pages. This Page class will contain all the locators of the Web Elements of that web page and will also contain methods that can perform operations on those Web Elements.

What are the benefits of using Page Object Pattern?

As we have seen that the Page Object Pattern provides flexibility to writing code by splitting into different classes and also keeps the test scripts separate from the locators. Considering this, few of the important benefits of the Page Object Model are:

  • Code reusability – The same page class can be used in different tests, and all the locators and their methods can be re-used across various test cases.
  • Code maintainability – There is a clean separation between test code which can be our functional scenarios and page-specific code such as locators and methods. So, if some structural change happens on the web page, it will just impact the page object and will not have any impacts on the test scripts.

💡 From personal experience on enterprise projects, there is one advice I can give you - Sometimes you will have to test pages of web applications that are very complex, have multiple large features and require a massive amount of test cases. In this case, you would NOT create only one page object class to store all page objects and methods for that page. What you will do to make code more maintainable is to separate page object classes by components (features) and not by entire page full of complex features. That is also a valid strategy, used mostly in enterprise projects.

👉 Fun fact: this is also how developers structure their code, usually with components (frontend) or microservices (backend). This means breaking down an enterprise app into smaller projects with large amount of smaller features/components/services etc.

So, let’s get our hands dirty and see how we can implement this strategy in our demo app.

We will use Book Store app within our Demoqa app. You can find it here:

book store section

In this Book Store App, we have login/registration page, book store page where you can find all books, and profile page where you can see your personal collection and do a log out.

So, in real life, before you start doing any automation, you first need to come up with test scenarios and write documents for it. For the purposes of this lesson, I came up with a few scenarios:

auth test cases

As you can see above, we have some repetitive steps such as navigating to login page, typing in login form, clicking on login etc. So for those, let’s create two page object classes under support/bookstore_page_objects folders.

1: First one, let’s call it auth.js and let's write there every page object method we need for authentication.

export class Auth {
  login(user_name, password) {
    cy.get('#userName').type(user_name);
    cy.get('#password').type(password);
    cy.get('#login').click();
  }
}

export const auth = new Auth();
Enter fullscreen mode Exit fullscreen mode

Code explanation:

We have created a class Auth to store all page objects/methods related to login, registration etc. and we exported that class into one constant, so we can use it in other tests.

Inside the class we have created one function login that is storing our log in steps, so we can just call this function later in the test, without having to write all these elements and actions inside the test itself. login function is taking to parameters, username and password.

2: Second page object file, it can be about navigation to different pages. This is an example of that “component” mentality, where sometimes you will just create a page object logically, not strictly related to a certain page. Let’s name it navigation.js and write the following code inside:

export class NavigateTo {
  login() {
    cy.visit('/login');
  }
}

export const navigateTo = new NavigateTo();
Enter fullscreen mode Exit fullscreen mode

Code explanation:

We have created a class NavigateTo to store all navigation actions in one place. For this first set of login test cases, we need only one function that will navigate us to the login page.

Now that we have our auth and navigation page object files, and we also have existing user we can use from fixtures we added before (see the top of this lesson), let’s write above tests and translate them into automated code.

3: Create bookstore folder under e2e folder, and create file called login.cy.js. Put the following code inside:

/// <reference types="Cypress" />

import { auth } from '../../support/bookstore_page_objects/auth';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Auth: Login user', () => {
  // Navigate to login page
  beforeEach('Navigate to Login page', () => {
    navigateTo.login();
  });

  it('Check valid user credentials', () => {
    // Load users fixture
    cy.fixture('users').then((users) => {
      // Perform login
      auth.login(users.user2.username, users.user2.password);
    });
    // Verify that user is redirected to profile page (user is logged in)
    cy.url().should('contain', '/profile');
  });

  it('Check invalid user credentials', () => {
    // Perform login
    auth.login('invalid345', 'invalid345');
    // Verify that user is still on login page (user is not logged in)
    cy.url().should('contain', '/login');
    // Verify that error message is displayed
    cy.get('#output').should('contain', 'Invalid username or password!');
  });

  it('Check login with invalid username and valid password', () => {
    // Load users fixture
    cy.fixture('users').then((users) => {
      // Perform login
      auth.login('invalid345', users.user2.password);
    });
    // Verify that user is still on login page (user is not logged in)
    cy.url().should('contain', '/login');
    // Verify that error message is displayed
    cy.get('#output').should('contain', 'Invalid username or password!');
  });

  it('Check login with valid username and invalid password', () => {
    // Load users fixture
    cy.fixture('users').then((users) => {
      // Perform login
      auth.login(users.user2.username, 'invalid345');
    });
    // Verify that user is still on login page (user is not logged in)
    cy.url().should('contain', '/login');
    // Verify that error message is displayed
    cy.get('#output').should('contain', 'Invalid username or password!');
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

  • Lines 3-4: In order to use our page objects and methods from them, we need to import them in test file.
  • Lines 8-10: Before each test we are navigating to login page, by calling “login” function from “navigateTo” page object class constant.
  • Lines 12-20: We are writing the core of our first case, to login user with valid credentials. In order to use our user credentials, we need to load fixtures file - line: 14. Then, we are calling “auth” page object and method “login” and providing parameters valid user name and valid password from fixtures file. Then, Line 19 we are asserting that user is now on profile page.
  • Lines 22-57: We are essentially just covering other scenarios where users don’t enter valid credentials in the same way as above described, and we are writing assertions to verify that user is still on login page and error message is displayed.

🚨 VERY IMPORTANT: KEEP YOU ASSERTIONS OUT OF PAGE OBJECT FILES AND ITS METHODS. WRITE ASSERTIONS ONLY IN TEST FILES, AS PRESENTED ABOVE.


collections test cases (Add Book To Collection)

So, for this test case you can see that we have some new navigation actions, we also have some actions on book store page and profile page.

Therefore we need to add this actions to existing and new page objects.

1: First, let’s add navigation methods to existing navigation.js page object:

export class NavigateTo {
  login() {
    cy.visit('/login');
  }

  profile() {
    cy.visit('/profile');
  }

  bookStoreFromProfile() {
    cy.get('#gotoStore').click();
  }
}

export const navigateTo = new NavigateTo();
Enter fullscreen mode Exit fullscreen mode

Code explnation:

  • Line 6: We added new function that will navigate user via URL to profile page.
  • Line 10: We added new function that will navigate user to bookstore by clicking on button “Go To Book Store”

2: Then, let’s add book_store.js page objects file, to store actions from book store:

export class BookActions {
  addBookToCollection(book_name) {
    cy.contains(book_name).click();
    cy.get('.text-right > #addNewRecordButton').click();
  }
}

export const bookActions = new BookActions();
Enter fullscreen mode Exit fullscreen mode

Code explanation:

Same way we created previous page objects, for this page we are creating a new one with function addBookToCollection that will take a book name as parameter, and will perform action of clicking on the book in the table and clicking on button to add book to collection.

3: Then, we also need page object file related to profile actions such as deleting book from personal collection, so let’s create profile.js file to store its page objects:

export class ProfileActions {
  deleteBookFromTable(book_name, dialog_option) {
    // Find delete button for certain book name and delete book from table
    cy.get('.rt-tbody')
      .contains('.rt-tr-group', book_name)
      .then((row) => {
        cy.wrap(row).find('#delete-record-undefined').click();
        cy.get(`#closeSmallModal-${dialog_option}`).click();
      });
  }
}

export const profileActions = new ProfileActions();
Enter fullscreen mode Exit fullscreen mode

Code explanation:

Same as before, we created new page object class for profile actions, and we created one function deleteBookFromTable that will handle deletion of book from user table in profile (it will search table by book name and in that row it will find “delete” icon and it will click on it. Then, it will click on dialog option to delete/cancel deletion.

As you can see this function takes two parameters we need, and that is book name and also dialog option ('ok'/'cancel') to provide as part of element ID of confirmation dialog. We made it this way to be reusable for delete books tests.

4: Now that we have all page objects and actions in place, we can now write our test case. Create new test case under bookstore folder called addBookToProfile.cy.js and write following code inside:

/// <reference types="Cypress" />

import { auth } from '../../support/bookstore_page_objects/auth';
import { bookActions } from '../../support/bookstore_page_objects/book_store';
import { profileActions } from '../../support/bookstore_page_objects/profile';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Collections: Add Book To Collection', () => {
  // Perform login
  beforeEach('Perform login', () => {
    navigateTo.login();
    cy.fixture('users').then((users) => {
      auth.login(users.user2.username, users.user2.password);
    });
  });

  // Delete the book from collection
  afterEach('Delete book from profile collection', () => {
    cy.fixture('books').then((books) => {
      profileActions.deleteBookFromTable(books.collection1.Git, 'ok');
      cy.verifyWindowAlertText(`Book deleted.`);
      cy.get('.rt-tbody').should('not.contain', books.collection1.Git);
      cy.get('.rt-noData').should('contain', 'No rows found').should('be.visible');
    });
  });

  it('Check adding book to profile collection', () => {
    // Navigate to book store
    navigateTo.bookStoreFromProfile();
    // Load books fixture
    cy.fixture('books').then((books) => {
      // Add first books to collection
      bookActions.addBookToCollection(books.collection1.Git);
      // Handle alert and verify alert message
      cy.verifyWindowAlertText(`Book added to your collection.`);
      // Navigate to user profile and verify that book is in collection table
      navigateTo.profile();
      cy.get('.rt-tbody').find('.rt-tr-group').first().should('contain', books.collection1.Git);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

  • Line 3-6: we are importing all page object classes we need to use here.
  • Line 10-15: Preconditions: We are navigating to login page and performing login. Notice how we use page objects to do that, we are not writing actions inside of test case.
  • Line 18-25: Cleanup: We are deleting book from user table and verifying it is deleted. afterEach or so called cleanup will be executed after each test case, but it is usually written like this - after beforeEach hook. And then, we are writing actual test cases after we defined preconditions and cleanup states.
  • Line 27-40: Actual test case. We are navigating to book store from profile, we are loading fixture with books, adding one book from collection, we are verifying that alert is called with proper text (utilising custom command we wrote before), we are navigating back to profile page and asserting that book is added to user table.

Collections tests (Delete Book From Collection)

For this test case, we don’t need additional page objects, we have all we need in existing ones. So let’s translate this test case into automated one:

1: Create new test file called deleteBookFromProfile.cy.js and write following code inside:

/// <reference types="Cypress" />

import { auth } from '../../support/bookstore_page_objects/auth';
import { bookActions } from '../../support/bookstore_page_objects/book_store';
import { profileActions } from '../../support/bookstore_page_objects/profile';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Collections: Delete Book From Collection', () => {
  // Perform login
  beforeEach('Perform login', () => {
    navigateTo.login();
    cy.fixture('users').then((users) => {
      auth.login(users.user2.username, users.user2.password);
    });
  });

  // Add book to collection
  beforeEach('Add book to profile collection', () => {
    navigateTo.bookStoreFromProfile();
    cy.fixture('books').then((books) => {
      bookActions.addBookToCollection(books.collection1.SpeakingJS);
      cy.verifyWindowAlertText(`Book added to your collection.`);
    });
  });

  // Delete the book from collection
  after('Delete book from profile collection', () => {
    cy.fixture('books').then((books) => {
      profileActions.deleteBookFromTable(books.collection1.SpeakingJS, 'ok');
      cy.verifyWindowAlertText(`Book deleted.`);
    });
  });

  it('Check deleting book from profile collection - confirm deletion', () => {
    cy.fixture('books').then((books) => {
      // Navigate to user profile
      navigateTo.profile();
      // Check if book is in the collection table
      cy.get('.rt-tbody')
        .find('.rt-tr-group')
        .first()
        .should('contain', books.collection1.SpeakingJS);
      // Delete book from table - confirm deletion
      profileActions.deleteBookFromTable(books.collection1.SpeakingJS, 'ok');
      // Handle delete alert and verify message
      cy.verifyWindowAlertText(`Book deleted.`);
      // Verify that book is no longer in collection table and that table is empty
      cy.get('.rt-tbody').should('not.contain', books.collection1.SpeakingJS);
      cy.get('.rt-noData').should('contain', 'No rows found').should('be.visible');
    });
  });

  it('Check deleting book from profile collection - decline deletion', () => {
    cy.fixture('books').then((books) => {
      // Navigate to user profile
      navigateTo.profile();
      // Check if book is in the collection table
      cy.get('.rt-tbody')
        .find('.rt-tr-group')
        .first()
        .should('contain', books.collection1.SpeakingJS);
      // Cancel book deletion
      profileActions.deleteBookFromTable(books.collection1.SpeakingJS, 'cancel');
      // Verify that book is still in the table
      cy.get('.rt-tbody').should('contain', books.collection1.SpeakingJS);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

  • Lines 3-6: We are importing all page object classes we need
  • Lines 10-15: We are navigating to login page and performing login
  • Lines 18-24: We are adding book in collection (this is also precondition)
  • Lines 27-32: After last test we are deleting remaining book from collection
  • Lines 34-51: First test case, We are navigating to profile, verifying that book is in the table, we are clicking on delete icon and confirming deletion, we are checking alert and that book is no longer in the table.
  • Lines 53-68: Second test case, similar to first, except on line 55 we are providing ‘cancel’ argument to cancel deletion dialog and after we are verifying that book is still in the table.

Collections tests (Check Book Info)

1: For this test case we need to add one action in profile page object file (to click on book in user table collection and open details), so now it will look like this:

export class ProfileActions {
  deleteBookFromTable(book_name, dialog_option) {
    // Find delete button for certain book name and delete book from table
    cy.get('.rt-tbody')
      .contains('.rt-tr-group', book_name)
      .then((row) => {
        cy.wrap(row).find('#delete-record-undefined').click();
        cy.get(`#closeSmallModal-${dialog_option}`).click();
      });
  }

  checkBookData(book_name) {
    // Navigate to book info (open book from table)
    cy.get('.rt-tbody').find('.rt-tr-group').first().contains(book_name).click();
  }
}

export const profileActions = new ProfileActions();
Enter fullscreen mode Exit fullscreen mode

Code explanation:

As you can see on line 12 , we added new function checkBookData that will take book name as parameter and it will perform the action of finding the book by name in user table and clicking on it, in order to open book details.

2: With all this, we can translate our test case into automated one. Create a new test file and call it checkBookInfo.cy.js.Write the following code inside:

/// <reference types="Cypress" />

import { auth } from '../../support/bookstore_page_objects/auth';
import { bookActions } from '../../support/bookstore_page_objects/book_store';
import { profileActions } from '../../support/bookstore_page_objects/profile';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Collections: Check Book Info', () => {
  // Perform login
  beforeEach('Perform login', () => {
    navigateTo.login();
    cy.fixture('users').then((users) => {
      auth.login(users.user2.username, users.user2.password);
    });
  });

  // Add book to book collection
  beforeEach('Add book to profile collection', () => {
    navigateTo.bookStoreFromProfile();
    cy.fixture('books').then((books) => {
      bookActions.addBookToCollection(books.collection1.DesignPatternsJS);
      cy.verifyWindowAlertText(`Book added to your collection.`);
    });
  });

  // Delete book from collection
  afterEach('Delete book from profile collection', () => {
    navigateTo.profile();
    cy.fixture('books').then((books) => {
      profileActions.deleteBookFromTable(books.collection1.DesignPatternsJS, 'ok');
      cy.verifyWindowAlertText(`Book deleted.`);
    });
  });

  it('Check book info from profile table', () => {
    // Navigate to user profile
    navigateTo.profile();
    // Load books fixture
    cy.fixture('books').then((books) => {
      // Click on book in collection to open book info
      profileActions.checkBookData(books.collection1.DesignPatternsJS);
    });
    // Define book info elements
    const bookDataElements = [
      '#ISBN-label',
      '#title-label',
      '#subtitle-label',
      '#author-label',
      '#publisher-label',
      '#pages-label',
      '#description-label',
      '#website-label',
    ];
    // Check book info elements
    cy.elementVisible(bookDataElements);
    // Define data about the book
    const bookData = [
      '9781449331818',
      'Learning JavaScript Design Patterns',
      `A JavaScript and jQuery Developer's Guide`,
      'Addy Osmani',
      `O'Reilly Media`,
      '254',
    ];
    // Check data about the book
    cy.textExists(bookData);
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

  • Lines 3-6: we are importing all page object classes we need for this test
  • Lines 10-15: Precondition - login
  • Lines 18-24: Precondition - adding book to user collection
  • Lines 27-33: Cleanup/Postcondition - deleting book from user table
  • Lines 35-67: Our test case - we are navigating to user profile and using previously created method to open book we are clicking on the book in table to open details. We are then storing all elements from book details and all text from book details that interest us into two arrays, and we are calling custom commands we created earlier to verify if all elements and texts is present on the page.

Book store page tests (Search book in book store)

1: For this test case we need to add one function for searching book, inside book_store.js page object. Write additional function so that now you have this inside:

export class BookActions {
  addBookToCollection(book_name) {
    cy.contains(book_name).click();
    cy.get('.text-right > #addNewRecordButton').click();
  }

  searchCollection(book_name) {
    cy.get('#searchBox').type(book_name);
  }
}

export const bookActions = new BookActions();
Enter fullscreen mode Exit fullscreen mode

Code explanation:

On line 7 we added new function searchCollection that will type book name we provide in test, in search box of the book store.

2: With this, we can create our test case searchBookstore.cy.js and write this code in it:

/// <reference types="Cypress" />

import { auth } from '../../support/bookstore_page_objects/auth';
import { bookActions } from '../../support/bookstore_page_objects/book_store';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Bookstore: Search For Book', () => {
  // Perform login
  beforeEach('Perform login', () => {
    navigateTo.login();
    cy.fixture('users').then((users) => {
      auth.login(users.user2.username, users.user2.password);
    });
  });

  it('Check searching for existing book in book store', () => {
    // Navigate to bookstore
    navigateTo.bookStoreFromProfile();
    // Load books fixture
    cy.fixture('books').then((books) => {
      // Perform book search
      bookActions.searchCollection(books.collection1.DesignPatternsJS);
      // Verify that there is a book in filtered table (in search result)
      cy.get('.rt-tbody')
        .find('.rt-tr-group')
        .first()
        .should('contain', books.collection1.DesignPatternsJS);
    });
  });

  it('Check searching for non-existing book in book store', () => {
    // Define invalid book name
    const invalid_book_name = 'Game of Thrones';
    // Navigate to bookstore
    navigateTo.bookStoreFromProfile();
    // Perform book search
    bookActions.searchCollection(invalid_book_name);
    // Assert that there are no search results (no book in the table and table is empty)
    cy.get('.rt-tbody').should('not.contain', invalid_book_name);
    cy.get('.rt-noData').should('contain', 'No rows found').should('be.visible');
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

  • Lines 3-5: we are importing page objects
  • Lines 9-14: we are doing precondition to login
  • Lines 16-29: First test case, using page objects we are typing in search box of bookstore, searching for book and asserting that we found it
  • Lines 31-41: Second test case, same as above but in this case we defined non-existing title and we are checking that table doesn’t contain it.

Auth (Log out)

1: For this test case we will add new action in auth page object that will allow us to log out. Now auth.js page object file should look like this:

export class Auth {
  login(user_name, password) {
    cy.get('#userName').type(user_name);
    cy.get('#password').type(password);
    cy.get('#login').click();
  }

  logout() {
    cy.get('#submit').should('contain', 'Log out').click();
  }
}

export const auth = new Auth();
Enter fullscreen mode Exit fullscreen mode

Code explanation:

On the line 7 we added logout function that will click on log out button on user profile and preform log out.

2: And now we can write out test case logout.cy.js:

/// <reference types="Cypress" />

import { auth } from '../../support/bookstore_page_objects/auth';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Auth: Log out user', () => {
  // Perform login
  beforeEach('Perform login', () => {
    navigateTo.login();
    cy.fixture('users').then((users) => {
      auth.login(users.user2.username, users.user2.password);
    });
  });

  it('Check logging out user', () => {
    // Assert that user is on profile page
    cy.url().should('contain', '/profile');
    // Perform log out
    auth.logout();
    // Assert that user is on login page
    cy.url().should('contain', '/login');
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

Same as previous test cases we are importing page objects, writing preconditions, and then writing core of our test - in this case we are using logout action from auth page objects to perform log out. Of course, we are adding assertion to verify if user is actually logged out.

HOMEWORK:

For homework, you can add more scenarios for bookstore, following the above structure. Also read more about page objects from Internet sources, there are plenty 😄

Don’t forget to push everything you did today on Github 😉 Remember git commands?

git add .

git commit -am "add: reusability, book store tests"

git push

SEE YOU IN LESSON 10!

Completed code for this lesson

If you have learned something new, feel free to support my work by buying me a coffee ☕

Buy Me A Coffee

Top comments (0)