Diejenigen, die mit dem Testwerkzeug Cypress arbeiten, wissen, dass Cypress das perfekte moderne Tool für E2E-UI-Tests ist. Aber es hat immer noch ein paar Einschränkungen, von denen einige von den Cypress-Entwicklern festgelegt wurden. Eine der Einschränkungen ist die Beschränkung auf nur eine Superdomäne pro Test.
Hinweis: Während der Fertigstellung dieses Artikels wurde die Version 6.0 von Cypress veröffentlicht. Alle Code Snippets in diesem Artikel sind für die Versionen 5.1 - 5.6 bestimmt.
In einer der Anwendungen unseres Projekts ist die Anmeldung extern über ein 2-Faktor-SSO-Gateway (Single Sign-On) implementiert, wobei der Benutzer umgeleitet wird.
Normalerweise scheitert unser Test in Cypress, wenn wir versuchen auf diese Anwendung zuzugreifen, weil wir als Benutzer automatisch zum SSO-Gateway in einer anderen Domäne umgeleitet werden.
Es gibt zwei Möglichkeiten, wie man eine Anmeldung in einem Test implementieren kann:
Eine Anmeldung mit Netzwerkanforderungen wird empfohlen und sogar vom Cypress-Team verwendet, ist aber nicht immer möglich - zum Beispiel, wenn das SSO-Gateway extern konfiguriert ist und Sie keine Kontrolle darüber haben.
Schauen wir uns zunächst an, wie wir die SSO-Anmeldung über die UI-Anmeldung implementieren können.
Dazu müssen Sie einige Änderungen in der Cypress-Konfigurationsdatei (cypress.json) vornehmen:
{% c-block language="js" %}
{
"baseUrl": `${applicationUrl}`,
"experimentalNetworkStubbing": true,
"chromeWebSecurity": false
}
{% c-block-end %}
Zuerst muss 'chromeWebSecurity' auf 'false' gesetzt werden, was Cypress dazu veranlasst, keine herkunftsübergreifende Zugriffe mehr zu blockieren.
In unserem Fall wird der Test mit dieser Änderung erfolgreich sein, aber der Browser wird die Anmeldeseite aufgrund der SSO-Gateway-Inhaltssicherheitspolitik nicht anzeigen. Die Browserkonsole zeigt dann folgende Fehlermeldung:
Verweigert '${ssoGatewayUrl}' zu framen, weil ein Ancestor gegen die folgende Richtlinie der Inhaltssicherheitsrichtlinie verstößt: "frame-ancestors 'none'".
Um diese Einschränkung zu beheben, müssen wir 'experimentalNetworkStubbing' auf true setzen. Mit dieser neuen Funktion wird Cypress alle Anwendungsanforderungen (xhr, fetch usw.) abhören, und sie ermöglicht auch die Verwendung eines neuen Cypress-Befehls "cy.route2()", mit dem wir die Anwendungsanforderungen abfangen können.
Hinweis: Für Cypress 6.0 wurde der Befehl "cy.route2()" von den experimentellen Funktionen in die Standardfunktionen verschoben. In Version 6.0 brauchen Sie 'experimentalNetworkStubbing' nicht auf true zu setzen, und der Befehl heißt "cy.intercept()". Das Änderungsprotokoll der Version 6.0 finden Sie hier.
Während des Tests fangen wir Anfragen ab, die auf das SSO-Gateway zeigen.
{% c-block language="js" %}
describe('SSO resolve', () => {
it('login', () => {
cy.route2(`${ssoGatewayUrl}/**`).as('sso')
cy.visit('/')
});
})
{% c-block-end %}
Nun wird die Anmeldeseite normal angezeigt und man kann den Anmelde-Workflow ausführen.
TIPP: Wenn Ihr Login-Workflow den 2. Schritt als OTP-Token-Login (Token, der normalerweise von einer mobilen Authentisierungsanwendung bezogen wird) vollzogen hat, kann man das Cypress-Plugin 'cypress-otp' verwenden. Das hier zu finden ist: https://docs.cypress.io/plugins/index.html. Aber man muss sich bewusst sein, dass es kein offizielles Plugin ist - es wird von der Community erstellt. Mit diesem Plugin können wir einen OTP-Token über einen benutzerdefinierten Befehl mit einem secret_key erzeugen.
Für diejenigen, die eine schnellere Anmeldesequenz in Ihren Tests wünschen (zum Beispiel in "before()" oder "beforeEach()"-Hooks), können wir eine SSO-Anmeldung mit Netzwerkanforderungen implementieren. Die genaue Implementierung hängt hauptsächlich vom Anmelde-Workflow der Anwendung ab, so dass Sie diesen Workflow selbst im Webbrowser debuggen müssen (schauen Sie sich z.B. die Anforderungen für jeden Anmeldeschritt im Netzwerk-Tool für Browser-Entwickler an).
In unserem Fall war die Implementierung etwas schwieriger, weil das Anmeldeformular in der Benutzeroberfläche die stateId als Teil des Action-Attributs enthält (in der URL zur Umleitung).
Man kann den normalen Cypress-Befehl cy.request() zum Senden von Netzwerkanforderungen verwenden, aber für sauberere Testdateien würde ich die Erstellung benutzerdefinierter Befehle empfehlen.
Unser Workflow Case:
Benutzerdefinierte Befehle können in der Datei commands.js im Support-Ordner platziert werden, oder man kann eine eigene Datei erstellen und diese dann in die Datei support/commands.js importieren.
Erstellen wir zunächst einen benutzerdefinierten Befehl für die Anmeldung mit Benutzername/Passwort.
{% c-block language="js" %}
Cypress.Commands.add('ssoUserLogin', () => {
return new Cypress.Promise((resolve, reject) => {
Cypress.log()
const ssoUserLogin = async () => {
const params = new URLSearchParams();
params.append('username', `${username}`);
params.append('password', `${password}`);
params.append('login-form-type', 'pwd');
const response = await fetch(`${ssoUserLoginUrl}`, {
method: 'POST',
body: params
}).then(res => {
if (!res.ok) {
reject(res.statusText)
}
return res
})
return response
}
ssoUserLogin().then(resolve)
})
})
{% c-block-end %}
Wie man sehen kann, haben wir Cypress.Promise erstellt und verwenden die async/await-Methode. Dieser Befehl löst sich erst auf, wenn wir eine Antwort auf die Anfrage erhalten. Wenn die Anfrage fehlschlägt, lehnen wir dieses Versprechen ab und geben die Details der Anfrage zu Debugging-Zwecken in die Cypress-Browserkonsole zurück.
Der Befehl Cypress.log() zeigt benutzerdefinierte Befehle in der Cypress Benutzeroberfläche an.
Als Nächstes erstellen wir einen benutzerdefinierten Befehl zum Abrufen einer umgeleiteten URL mit einer stateId aus dem OTP-Anmeldeformular.
{% c-block language="js" %}
Cypress.Commands.add('ssoState', () => {
return new Cypress.Promise((resolve, reject) => {
Cypress.log()
const ssoState = async () => {
const response = await fetch(`${ssoOtpLoginFormUrl}`).then(res => {
if (!res.ok) {
reject(res.statusText)
}
return res.text()
}).then((text) => {
const html = document.createElement('html');
html.innerHTML = text;
const node = html.getElementsByTagName('form');
Cypress.env('actionUrl',node[0].attributes.action.value)
})
return response
}
ssoState().then(resolve)
})
})
{% c-block-end %}
Wir parsen die URL aus dem HTML-Element-Attribut und speichern sie als eine Cypress-Variable.
Schließlich erstellen wir einen letzten benutzerdefinierten Befehl für die OTP-Anmeldung.
{% c-block language="js" %}
Cypress.Commands.add('ssoOTPLogin', () => {
return new Cypress.Promise((resolve, reject) => {
Cypress.log()
const ssoOTPLogin = async () => {
const otpGenerator = require("cypress-otp")
const otp = otpGenerator(`${secret_key}`).toString()
const params = new URLSearchParams();
const stateId = Cypress.env('actionUrl')
params.append('otp', otp);
params.append('operation', 'verify');
const response = await fetch(`${ssoOtpLoginUrl}${stateId}`, {
method: 'POST',
body: params
}).then(res => {
if (!res.ok) {
reject(res.statusText)
}
return res
})
return response
}
ssoOTPLogin().then(resolve)
})
})
{% c-block-end %}
Für mehr Komfort kann man einen zusätzlichen benutzerdefinierten Befehl erstellen, um die zuvor erstellten benutzerdefinierten Befehle zu einem einzigen zusammenzuführen.
{% c-block language="js" %}
Cypress.Commands.add("login", () => {
cy.ssoUserLogin()
cy.ssoState()
cy.ssoOTPLogin()
})
{% c-block-end %}
Jetzt haben wir einen funktionierenden Login-Workflow über Netzwerkanfragen, aber Moment ... es funktioniert nur in Electron. In Chrome wird dieser Login-Workflow fehlschlagen und der Benutzer wird wieder zum SSO-Gateway umgeleitet.
Der Grund dafür ist, dass diese Anfragen Cookies in Antwortkopfzeilen erhalten (um das Login zu bewahren), die im Browser gesetzt werden sollten, aber Chrome hat die aktivierte Funktion SameSiteByDefaultCookies (von Chrome 84).
Diese Funktion blockiert die Einrichtung von Cookies im Browser, wenn der Antwortkopf "Set-Cookie" nicht "SameSite=none" für die herkunftsübergreifende Verwendung enthält.
Glücklicherweise können wir diese Funktion für Chrome via Flag vor dem Start des Browsers deaktivieren.
Diese Einstellung wird in der Datei plugins/index.js innerhalb der Funktion module.exports vorgenommen.
{% c-block language="js" %}
module.exports = (on, config) => {
on('before:browser:launch', (browser = {}, launchOptions) => {
console.log(launchOptions.args) // print all current args
if (browser.family === 'chromium' && browser.name !== 'electron') {
launchOptions.args.push('--disable-features=SameSiteByDefaultCookies')
// whatever you return here becomes the launchOptions
return launchOptions
}
})
}
{% c-block-end %}
Wenn Sie mehr über Testverfahren erfahren möchten, können Sie meinen früheren Blog-Eintrag lesen, unsere sozialen Medien verfolgen oder mich gerne direkt kontaktieren.