Testy integracyjne Seam
Poza zwykłymi testami jednostkowymi czy testami komponentów, Seam pozwala wykonywać testy integracyjne w środowisku symulującym JSF.
Jako przykładu użyję zmiany hasła użytkownika. Do tego celu będę potrzebował choćby prostego uwierzytelnienia, na którym oprę działanie nowego komponentu.
Interfejs
Interfejs komponentu musi posiadać właściwości pozwalające na podanie nazwy użytkownika (login), starego hasła (oldPassword), nowego hasła (newPassword) oraz powtórzenia nowego hasła (repeatedNewPassword). Potrzebna też będzie akcja changePassword, wykonująca właściwą operację:
package pl.info.czerwinski;
import javax.ejb.Local;
@Local
public interface ChangePassword {
public String getLogin();
public void setLogin(String login);
public String getOldPassword();
public void setOldPassword(String password);
public String getNewPassword();
public void setNewPassword(String password);
public String getRepeatedNewPassword();
public void setRepeatedNewPassword(String password);
public String changePassword();
}
Komponent
Zmiana hasła jest prostą operacją, wykorzystującą dane z formularza. Dlatego wystarczy komponent bezstanowy w kontekście zdarzenia:
package pl.info.czerwinski;
import javax.ejb.Stateless;
import org.jboss.seam.ScopeType;
import org.jboss.seam.annotations.In;
import org.jboss.seam.annotations.Name;
import org.jboss.seam.annotations.Out;
import org.jboss.seam.annotations.Scope;
import org.jboss.seam.international.StatusMessage.Severity;
import org.jboss.seam.international.StatusMessages;
import org.jboss.seam.security.Credentials;
import org.jboss.seam.security.management.IdentityStore;
@Stateless
@Name("changePassword")
@Scope(ScopeType.EVENT)
public class ChangePasswordAction implements ChangePassword {
}
Login i hasło będą przechowywane w danych uwierzytelniających (credentials) wykorzystywanych też w komponencie tożsamości (identity):
@In @Out private Credentials credentials;
public String getLogin() {
return credentials.getUsername();
}
public void setLogin(String login) {
credentials.setUsername(login);
}
public String getOldPassword() {
return credentials.getPassword();
}
public void setOldPassword(String password) {
credentials.setPassword(password);
}
Zastosowanie adnotacji @In i @Out zapewni automatyczne wypełnienie nazwy zalogowanego użytkownika (hasło jest czyszczone zaraz po uwierzytelnieniu).
Obie wartości nowego hasła będą przechowywane w zwykłych polach prywatnych:
private String newPassword;
private String repeatedNewPassword;
public String getNewPassword() {
return newPassword;
}
public void setNewPassword(String password) {
newPassword = password;
}
public String getRepeatedNewPassword() {
return repeatedNewPassword;
}
public void setRepeatedNewPassword(String password) {
repeatedNewPassword = password;
}
Do zmiany hasła potrzebny będzie magazyn tożsamości (identityStore). Przydadzą się także komunikaty (statusMessages) oraz stałe zawierające identyfikatory widoków wyświetlanych w przypadku zmiany hasła zakończonej sukcesem (SUCCESS_VIEW) lub porażką (FAILURE_VIEW):
private static final String SUCCESS_VIEW = "/home.xhtml"; private static final String FAILURE_VIEW = "/changePass.xhtml"; @In private IdentityStore identityStore; @In private StatusMessages statusMessages;
Przed zmianą hasła należy sprawdzić, czy dane uwierzytelniające są prawidłowe (linie 3–10). Ważne jest też, aby nowe hasło oraz jego powtórzenie były takie same (linie 12–17). Na sam koniec trzeba się upewnić, że zmiana hasła przebiegnie prawidłowo (linie 19–25), po czym można przekierować użytkownika na właściwą stronę (linia 27):
public String changePassword() {
// Check login and password:
if (!identityStore.authenticate(
credentials.getUsername(),
credentials.getPassword())) {
statusMessages.add(
Severity.WARN,
"Nieprawidłowy login lub hasło.");
return FAILURE_VIEW;
}
// Check if both new passwords are equal:
if (!newPassword.equals(repeatedNewPassword)) {
statusMessages.add(
Severity.WARN,
"Powtórzone hasło inne niż nowe hasło.");
return FAILURE_VIEW;
}
// Try to change the password:
if (!identityStore.changePassword(
credentials.getUsername(), newPassword)) {
statusMessages.add(
Severity.WARN,
"Operacja nie powiodła się.");
return FAILURE_VIEW;
}
// Success:
return SUCCESS_VIEW;
}
Dane testowe
Przed rozpoczęciem testów, wymagane będzie dodanie przynajmniej jednego użytkownika do bazy danych. W tym celu należy wstawić nowe polecenie w pliku import-test.sql:
insert into users (login, passwd) values ('admin', 'admin');
Jeżeli hasło jest szyfrowane metodą hash="md5", będzie ono miało postać 'Eyox7xbNQ09MkIfRyH+rjg=='.
Testy
Do testów znowu wykorzystam klasę SeamTest:
package pl.info.czerwinski;
import org.jboss.seam.mock.SeamTest;
import org.testng.annotations.Test;
public class ChangePasswordTest extends SeamTest {
}
Jednak tym razem SeamTest.ComponentTest nie wystarczy. Przyda się za to klasa SeamTest.FacesRequest.
Pierwszy przypadek testowy będzie sprawdzał prawidłowe działanie akcji. Żądanie JSF rozpocznie się na stronie /changePass.xhtml (↓ linia 3).
Wewnątrz metody updateModelValues ustawiam wartości normalnie pobierane z formularza (↓ linie 4–11).
Metoda invokeApplication zawiera wywołanie akcji, uruchamianej zazwyczaj za pomocą przycisku lub odnośnika (↓ linie 12–16). Dodatkowo sprawdzany jest wynik tej operacji, który powinien być identyfikatorem widoku wyświetlanego w przypadku sukcesu.
Wewnątrz metody renderResponse można dokonać weryfikacji danych wyświetlanych po wykonaniu żądania (↓ linie 17–22). W tym wypadku poprawne działanie komponentu powinno zagwarantować brak komunikatów (facesContext.maximumSeverity == null):
@Test
public void testChangePassword() throws Exception {
new FacesRequest("/changePass.xhtml") {
@Override
protected void updateModelValues() throws Exception {
setValue("#{changePassword.login}", "admin");
setValue("#{changePassword.oldPassword}", "admin");
setValue("#{changePassword.newPassword}", "newpass");
setValue("#{changePassword.repeatedNewPassword}",
"newpass");
}
@Override
protected void invokeApplication() throws Exception {
assert invokeMethod("#{changePassword.changePassword}")
.equals("/home.xhtml") : "Password change falis.";
}
@Override
protected void renderResponse() throws Exception {
assert getValue(
"#{facesContext.maximumSeverity}") == null :
"There are some faces messages.";
}
}.run();
}
Kolejna metoda będzie testować tę samą akcję, ale przy nieprawidłowych danych uwierzytelniających. Ponieważ hasło ma tutaj duże znaczenie, upewniam się, że metoda ta zostaniw wywołana po testChangePassword (↓ linia 1).
Dane wejściowe formularza będą takie same jak w pierwszym teście, ale tym razem hasło będzie już zmienione. Dlatego też spodziewam się zmiany hasła zakończonej porażką (↓ linie 14–16). Dodatkowo na stronie (↓ linie 20–22) powinien zostać wyświetlony odpowiedni komunikat (facesContext.maximumSeverity != null):
@Test(dependsOnMethods="testChangePassword")
public void testWrongCredentials() throws Exception {
new FacesRequest("/changePass.xhtml") {
@Override
protected void updateModelValues() throws Exception {
setValue("#{changePassword.login}", "admin");
setValue("#{changePassword.oldPassword}", "admin");
setValue("#{changePassword.newPassword}", "newpass");
setValue("#{changePassword.repeatedNewPassword}",
"newpass");
}
@Override
protected void invokeApplication() throws Exception {
assert invokeMethod("#{changePassword.changePassword}")
.equals("/changePass.xhtml") :
"Password change succeeds.";
}
@Override
protected void renderResponse() throws Exception {
assert getValue(
"#{facesContext.maximumSeverity}") != null :
"There are no faces messages.";
}
}.run();
}
Następny przypadek testowy (również wywoływany po testChangePassword) przewiduje poprawne dane uwierzytelniające. Jednak teraz nowe hasło (newPassword) różni się od powtórzonego hasła (repeatedNewPassword). Tym razem spodziewam się takich samych wyników, jak dla testWrongCredentials:
@Test(dependsOnMethods="testChangePassword")
public void testDifferentRepeated() throws Exception {
new FacesRequest("/changePass.xhtml") {
@Override
protected void updateModelValues() throws Exception {
setValue("#{changePassword.login}", "admin");
setValue("#{changePassword.oldPassword}", "newpass");
setValue("#{changePassword.newPassword}", "newpass1");
setValue("#{changePassword.repeatedNewPassword}",
"newpass2");
}
@Override
protected void invokeApplication() throws Exception {
assert invokeMethod("#{changePassword.changePassword}")
.equals("/changePass.xhtml") :
"Password change succeeds.";
}
@Override
protected void renderResponse() throws Exception {
assert getValue(
"#{facesContext.maximumSeverity}") != null :
"There are no faces messages.";
}
}.run();
}
Dzięki klasie FacesRequest mogę testować także bardziej złożone scenariusze. Jeden przypadek testowy może składać się z dowolnie wielu żądań JSF.
Ostatnia metoda testowa będzie ponownie zmieniać hasło użytkownika na pierwotną wartość. Z tego względu wywołam ją po testWrongCredentials i testDifferentRepeated (↓ linie 1–4).
Pierwsze żądanie (↓ linie 7–22) odpowiada zalogowaniu użytkownika do systemu.
Następnie (↓ linie 24–43) dokonywana jest zmiana hasła. Nazwa użytkownika powinna być przechowywana w danych uwierzytelniających tożsamości (credentials), więc jej ustawienie w formularzu jest zbędne – przynajmniej w teorii.
Po zmianie hasła, jako ostatnia operacja, następuje wylogowanie użytkownika (↓ linie 45–55):
@Test(dependsOnMethods={
"testWrongCredentials",
"testDifferentRepeated"
})
public void testRememberCredentials() throws Exception {
// Log in:
new FacesRequest("/login.xhtml") {
@Override
protected void updateModelValues() throws Exception {
setValue("#{credentials.username}", "admin");
setValue("#{credentials.password}", "newpass");
}
@Override
protected void invokeApplication() throws Exception {
invokeMethod("#{identity.login}");
}
@Override
protected void renderResponse() throws Exception {
assert (Boolean) getValue("#{identity.loggedIn}") :
"Not logged in.";
}
}.run();
// Change password:
new FacesRequest("/changePass.xhtml") {
@Override
protected void updateModelValues() throws Exception {
setValue("#{changePassword.oldPassword}", "newpass");
setValue("#{changePassword.newPassword}", "admin");
setValue("#{changePassword.repeatedNewPassword}",
"admin");
}
@Override
protected void invokeApplication() throws Exception {
assert invokeMethod("#{changePassword.changePassword}")
.equals("/home.xhtml") : "Password change falis.";
}
@Override
protected void renderResponse() throws Exception {
assert getValue(
"#{facesContext.maximumSeverity}") == null :
"There are some faces messages.";
}
}.run();
// Log out:
new FacesRequest("/home.xhtml") {
@Override
protected void invokeApplication() throws Exception {
invokeMethod("#{identity.logout}");
}
@Override
protected void renderResponse() throws Exception {
assert (Boolean) getValue("#{not identity.loggedIn}") :
"Still logged in.";
}
}.run();
}
Podsumowanie
Za pomocą opisanych tu testów integracyjnych można sprawdzić poprawność działania całej aplikacji, poza samym widokiem. Jeżeli jakaś funkcjonalność aplikacji przechodzi testy a mimo to nie działa poprawnie, to właśnie w widoku trzeba szukać problemu.
Cała trudność polega na sporządzeniu jak najlepszych scenariuszy testowych i uzupełnianiu ich w razie potrzeby.



