Cypress - verifying presence of alias or comparing text in alias to string in 'if' statement

196 Views Asked by At

I'm creating an API automation suite with Cypress.

I have API tests which don't require logging in, and ones that do.

I have written a utility method called 'callAPI' which all my tests will use - it has a bunch of error checking and automatically augments calls with relevant headers/authorization token, depending on what the caller passes into the method.

I also have a 'logIn' method which creates an alias storing an authorization token.

In the scenario where the automation engineer calls callAPI to do an API request that requires logging in, I want to be able to detect that they haven't logged in and throw an error message advising as such.

Approach 1: If the alias 'accessToken' doesn't exist, throw the error.

Problem: It appears that the use of cy.state('aliases') has been deprecated.

Approach 2: Create the alias in the 'before' hook with the default value 'accessToken not set'...

before(() => {
    cy.wrap('accessToken not set').as('accessToken');
});

Then in callAPI, check if the alias's value is equal to 'accessToken not set' and throw the error.

Problems:

if (useAccessToken && cy.get('@accessToken') == 'accessToken not set') {
    throw new Error('callAPI() - access token not set. Log in first.');
}

if (useAccessToken && cy.get('@accessToken').invoke('text') == 'accessToken not set') {
    throw new Error('callAPI() - access token not set. Log in first.');
}
    

First 'if' statement displays "This comparison appears to be unintentional because the types 'Chainable' and 'string' have no overlap."

Second 'if' statement displays "This comparison appears to be unintentional because the types 'Chainable' and 'string' have no overlap."

I've perhaps mistakenly assumed that it should be trivial to compare the text within an alias in an 'if' statement, but apparently this is not a thing...?

Approach 3: The intention here would be to do the comparison within the 'then'....

cy.get('@accessToken').then(data => {
    cy.log(data.text());
});

Problem: "data.text is not a function"

Approach 4: Within my 'if' statement, assert that the alias's text equals 'accessToken not set'.

if (useAccessToken && cy.get('@accessToken').should('equal', 'accessToken not set')) {
    throw new Error('callAPI() - access token not set. Log in first.');
}

Problem: If this assertion fails, Cypress throws its own error, defeating the object of throwing my custom error.

My code:

const apiURL = Cypress.env('apiUrl');

export const APIURLs = {
    Login: `${apiURL}access/login`,
    Info: `${apiURL}info`,
    InfoHealth: `${apiURL}info/health`
};

export class Common {
    logInAndSetAccessToken(emailAddress: string, password: string) {
        this.callAPI({
            requestObject: {
                method: 'POST',
                url: APIURLs.Login,
                body: {
                    email: emailAddress,
                    password: password
                }
            }, useAccessToken: false
        }).then(response => {
            cy.wrap(response.body.tokens.accessToken).as('accessToken');
        });
    }

    /**
     * 'headers' property, along with the following headers are automatically added to requestObject:
     * 
     * 'Content-Type': 'application/json; charset=utf-8'
     * 
     * 'Authorization': `Bearer ${accessToken}`
     */
    callAPI({ url = '', requestObject = {}, useAccessToken = true } = {}) {
        if (url.length > 3 && Object.keys(requestObject).length > 0) {
            throw new Error('callAPI() - method call ambigious. Pass url or requestObject, not both.');
        }

        if (url.length < 4 && Object.keys(requestObject).length == 0) {
            throw new Error('callAPI() - method call missing necessary information to make API call. Pass url or requestObject.');
        }

        if (useAccessToken && cy.get('@accessToken') == 'accessToken not set') { // This comparison appears to be unintentional because the types 'Chainable<JQuery<HTMLElement>>' and 'string' have no overlap.
            throw new Error('callAPI() - access token not set. Log in first.');
        }

        if (Object.keys(requestObject).length > 0) {
            if (!requestObject.method || !requestObject.url || !requestObject.body) {
                throw new Error('callAPI() - method, url or body properties are missing in the requestObject.');
            }

            if (!requestObject.headers) {
                Object.assign(requestObject, { headers: {} });
            }

            if (!requestObject.headers['Content-Type']) {
                Object.assign(requestObject.headers, { 'Content-Type': 'application/json; charset=utf-8' });
            }

            if (useAccessToken && !requestObject.headers['Authorization']) {
                Object.assign(requestObject.headers, { 'Authorization': `Bearer ${cy.get('@accessToken')}` });
            }

            return cy.request(requestObject);
        } else {
            if (url.length < 4) {
                throw new Error('callAPI() - invalid url, cannot call API.');
            }

            return cy.request(url);
        }
    }
}

Unless I'm missing the obvious (wouldn't surprise me), is there any way to address this problem? Or should I just rely on the API informing the automation engineer that they need to log in?

I'm using TypeScript if that matters.

Thank you for any help you may be able to offer.

4

There are 4 best solutions below

0
Zuno On BEST ANSWER

Thank you everyone for helping.

I ended up separating the logIn method and callAPI method, calling the logIn method within callAPI if the access token needs to be used.

export class Common {
    /**
     * Strictly for testing logging in - DOES NOT save access token for future calls.
     */
    logIn(emailAddress: string, password: string) {
        return cy.request({
            method: 'POST',
            url: APIURLs.AccessLogin,
            body: {
                email: emailAddress,
                password: password
            }
        });
    }

    /**
     * For API calls that require logging in/access token, pass userObject.
     */
    callAPI({ url = '', requestObject = {}, useAccessToken = true, userObject = {} } = {}) {
        if (url.length > 0 && Object.keys(requestObject).length > 0) {
            throw new Error('callAPI() - method call ambigious. Pass url or requestObject, not both.');
        }

        if (url.length < (apiURL.length + 4) && Object.keys(requestObject).length == 0) {
            throw new Error('callAPI() - method call missing necessary information to make API call. Pass url or requestObject.');
        }

        if (useAccessToken && Object.keys(userObject).length == 0) {
            throw new Error('callAPI() - cannot use access token without passing a userObject from configuration.');
        }

        if (Object.keys(requestObject).length > 0) {
            if (!requestObject.method || !requestObject.url || !requestObject.body) {
                throw new Error('callAPI() - method, url or body properties are missing in the requestObject.');
            }

            if (!requestObject.headers) {
                Object.assign(requestObject, { headers: {} });
            }

            if (!requestObject.headers['Content-Type']) {
                Object.assign(requestObject.headers, { 'Content-Type': 'application/json; charset=utf-8' });
            }
        } else {
            if (url.length < (apiURL.length + 4)) {
                throw new Error('callAPI() - invalid url, cannot call API.');
            }
        }

        if (useAccessToken) {
            return this.logIn(userObject.Email, userObject.m_UIPassword).then(response => {
                const accessToken = response.body.tokens.accessToken;

                if (!requestObject.headers['Authorization']) {
                    Object.assign(requestObject.headers, { 'Authorization': `Bearer ${accessToken}` });
                } else {
                    requestObject.headers['Authorization'] = `Bearer ${accessToken}`;
                }

                return cy.request(requestObject);
            });
        } else {
            if (Object.keys(requestObject).length > 0) {
                return cy.request(requestObject);
            } else {
                return cy.request(url);
            }
        }
    }
}
0
agoff On
  1. Using cy.get('@alias') values directly won't be possible in the if statements themselves, as cy.get() yields a Chainable<Any> type. So, comparing cy.get('@alias') or cy.get('@alias').invoke('text') yields a Chainable element, and not a string.
  2. Aliases are reset on a per-test basis, so if you did want to set the alias before the tests, you'd need to do so in a beforeEach() instead of a before().
  3. cy.get() commands contain an implicit assertion that the reference exists, meaning that if cy.get('@alias') is not found, Cypress will fail the test.
  4. Luckily, we don't have to use cy.get() to retrieve an alias value - we can use Mocha's shared context to reference the values via this.alias.

So, considering all of that, a potential solution could be:

describe('tests', () => {
  beforeEach(() => {
    cy.wrap('accessToken not set').as('accessToken');
  });

  it('tests something', function () {
    if (useAccessToken && this.accessToken == 'accessToken not set') {
      throw new Error('callAPI() - access token not set. Log in first.');
    }
  });
});

Unfortunately, using this.* requires the functionality to be called in something that has the Mocha shared context object, and your helper function does not appear to have that context.

Instead, we could use a Cypress environment variable, which will allow for synchronous checking and return a string variable for evaluation.

logInAndSetAccessToken(emailAddress: string, password: string) {
        this.callAPI({
            requestObject: {
                method: 'POST',
                url: APIURLs.Login,
                body: {
                    email: emailAddress,
                    password: password
                }
            }, useAccessToken: false
        }).then(response => {
            Cypress.env('accessToken', response.body.tokens.accessToken)
        });
    }

...

if (useAccessToken && Cypress.env('accessToken') == 'accessToken not set') { // This comparison appears to be unintentional because the types 'Chainable<JQuery<HTMLElement>>' and 'string' have no overlap.
            throw new Error('callAPI() - access token not set. Log in first.');
        }

To note: Cypress environment variables are only reset on a per-spec basis, so the access token will carry over from test-to-test in the same spec file. If you want to avoid this, you can simply set the "default" value of the environment variable in your beforeEach() (Cypress.env('accessToken', 'accessToken not set');)

0
TesterDick On

Most of your approaches will work with combining the best bits of each.

Comparison within the 'then' and a custom error

Combine your approaches, you have something that works

cy.wrap('some-token').as('accessToken')
cy.get('@accessToken').then(data => {
  if(data === 'accessToken not set') {
    throw new Error('callAPI() - access token not set. Log in first.')
  }
})

cy.wrap('accessToken not set').as('accessToken')
cy.get('@accessToken').then(data => {
  if(data === 'accessToken not set') {
    throw new Error('callAPI() - access token not set. Log in first.')
  }
})

enter image description here

Using assert() to customize the message

You can use assert with custom message to do a similar thing

function aliasIsSet(data) {
  const isSet = (data !== 'accessToken not set')
  const msg = isSet ? 'access token is set'
    :'callAPI() - access token not set. Log in first.' 
  assert(isSet, msg, {log:!isSet})
}

cy.wrap('some-token').as('accessToken')
cy.get('@accessToken').then(aliasIsSet)

cy.wrap('accessToken not set').as('accessToken')
cy.get('@accessToken').then(aliasIsSet)

enter image description here

0
Aladin Spaz On

You could take a proactive approach and make the call when the token is not set.

Here I set the alias in the constructor so that it always has a value, so that Cypress won't break when calling cy.get('@accessToken').

export class Common {

    constructor() {
      cy.wrap('accessToken not set').as('accessToken')
    }

    logInAndSetAccessToken(emailAddress: string, password: string) {
        this.callAPI({
          ...
          }, useAccessToken: false
        }).then(response => {
            cy.wrap(response.body.tokens.accessToken).as('accessToken');
        });
    }

    callAPI({ url = '', requestObject = {}, useAccessToken = true } = {}) {

      cy.get('@accessToken').then(data => {
        if(useAccessToken && data === 'accessToken not set') {
          logInAndSetAccessToken(emailAddress, password)
        }
      })

      if (url.length > 3 && Object.keys(requestObject).length > 0) {

      ... rest of this method

Or you can avoid issues with aliases altogether by using a class property

export class Common {

    logInAndSetAccessToken(emailAddress: string, password: string) {
        this.callAPI({
          ...
          }, useAccessToken: false
        }).then(response => {
            this.token = response.body.tokens.accessToken;
        });
    }

    callAPI({ url = '', requestObject = {}, useAccessToken = true } = {}) {

        if(useAccessToken && !this.token) {
          logInAndSetAccessToken(emailAddress, password)
        }

        if (url.length > 3 && Object.keys(requestObject).length > 0) {

        ... rest of this method